Path: blob/main/src/vs/base/browser/ui/contextview/contextview.ts
3296 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { BrowserFeatures } from '../../canIUse.js';6import * as DOM from '../../dom.js';7import { StandardMouseEvent } from '../../mouseEvent.js';8import { Disposable, DisposableStore, IDisposable, toDisposable } from '../../../common/lifecycle.js';9import * as platform from '../../../common/platform.js';10import { Range } from '../../../common/range.js';11import { OmitOptional } from '../../../common/types.js';12import './contextview.css';1314export const enum ContextViewDOMPosition {15ABSOLUTE = 1,16FIXED,17FIXED_SHADOW18}1920export interface IAnchor {21x: number;22y: number;23width?: number;24height?: number;25}2627export function isAnchor(obj: unknown): obj is IAnchor | OmitOptional<IAnchor> {28const anchor = obj as IAnchor | OmitOptional<IAnchor> | undefined;2930return !!anchor && typeof anchor.x === 'number' && typeof anchor.y === 'number';31}3233export const enum AnchorAlignment {34LEFT, RIGHT35}3637export const enum AnchorPosition {38BELOW, ABOVE39}4041export const enum AnchorAxisAlignment {42VERTICAL, HORIZONTAL43}4445export interface IDelegate {46/**47* The anchor where to position the context view.48* Use a `HTMLElement` to position the view at the element,49* a `StandardMouseEvent` to position it at the mouse position50* or an `IAnchor` to position it at a specific location.51*/52getAnchor(): HTMLElement | StandardMouseEvent | IAnchor;53render(container: HTMLElement): IDisposable | null;54focus?(): void;55layout?(): void;56anchorAlignment?: AnchorAlignment; // default: left57anchorPosition?: AnchorPosition; // default: below58anchorAxisAlignment?: AnchorAxisAlignment; // default: vertical59canRelayout?: boolean; // default: true60onDOMEvent?(e: Event, activeElement: HTMLElement): void;61onHide?(data?: unknown): void;6263/**64* context views with higher layers are rendered higher in z-index order65*/66layer?: number; // Default: 067}6869export interface IContextViewProvider {70showContextView(delegate: IDelegate, container?: HTMLElement): void;71hideContextView(): void;72layout(): void;73}7475export interface IPosition {76top: number;77left: number;78}7980export interface ISize {81width: number;82height: number;83}8485export interface IView extends IPosition, ISize { }8687export const enum LayoutAnchorPosition {88Before,89After90}9192export enum LayoutAnchorMode {93AVOID,94ALIGN95}9697export interface ILayoutAnchor {98offset: number;99size: number;100mode?: LayoutAnchorMode; // default: AVOID101position: LayoutAnchorPosition;102}103104/**105* Lays out a one dimensional view next to an anchor in a viewport.106*107* @returns The view offset within the viewport.108*/109export function layout(viewportSize: number, viewSize: number, anchor: ILayoutAnchor): number {110const layoutAfterAnchorBoundary = anchor.mode === LayoutAnchorMode.ALIGN ? anchor.offset : anchor.offset + anchor.size;111const layoutBeforeAnchorBoundary = anchor.mode === LayoutAnchorMode.ALIGN ? anchor.offset + anchor.size : anchor.offset;112113if (anchor.position === LayoutAnchorPosition.Before) {114if (viewSize <= viewportSize - layoutAfterAnchorBoundary) {115return layoutAfterAnchorBoundary; // happy case, lay it out after the anchor116}117118if (viewSize <= layoutBeforeAnchorBoundary) {119return layoutBeforeAnchorBoundary - viewSize; // ok case, lay it out before the anchor120}121122return Math.max(viewportSize - viewSize, 0); // sad case, lay it over the anchor123} else {124if (viewSize <= layoutBeforeAnchorBoundary) {125return layoutBeforeAnchorBoundary - viewSize; // happy case, lay it out before the anchor126}127128if (viewSize <= viewportSize - layoutAfterAnchorBoundary) {129return layoutAfterAnchorBoundary; // ok case, lay it out after the anchor130}131132return 0; // sad case, lay it over the anchor133}134}135136export class ContextView extends Disposable {137138private static readonly BUBBLE_UP_EVENTS = ['click', 'keydown', 'focus', 'blur'];139private static readonly BUBBLE_DOWN_EVENTS = ['click'];140141private container: HTMLElement | null = null;142private view: HTMLElement;143private useFixedPosition = false;144private useShadowDOM = false;145private delegate: IDelegate | null = null;146private toDisposeOnClean: IDisposable = Disposable.None;147private toDisposeOnSetContainer: IDisposable = Disposable.None;148private shadowRoot: ShadowRoot | null = null;149private shadowRootHostElement: HTMLElement | null = null;150151constructor(container: HTMLElement, domPosition: ContextViewDOMPosition) {152super();153154this.view = DOM.$('.context-view');155DOM.hide(this.view);156157this.setContainer(container, domPosition);158this._register(toDisposable(() => this.setContainer(null, ContextViewDOMPosition.ABSOLUTE)));159}160161setContainer(container: HTMLElement | null, domPosition: ContextViewDOMPosition): void {162this.useFixedPosition = domPosition !== ContextViewDOMPosition.ABSOLUTE;163const usedShadowDOM = this.useShadowDOM;164this.useShadowDOM = domPosition === ContextViewDOMPosition.FIXED_SHADOW;165166if (container === this.container && usedShadowDOM === this.useShadowDOM) {167return; // container is the same and no shadow DOM usage has changed168}169170if (this.container) {171this.toDisposeOnSetContainer.dispose();172173this.view.remove();174if (this.shadowRoot) {175this.shadowRoot = null;176this.shadowRootHostElement?.remove();177this.shadowRootHostElement = null;178}179180this.container = null;181}182183if (container) {184this.container = container;185186if (this.useShadowDOM) {187this.shadowRootHostElement = DOM.$('.shadow-root-host');188this.container.appendChild(this.shadowRootHostElement);189this.shadowRoot = this.shadowRootHostElement.attachShadow({ mode: 'open' });190const style = document.createElement('style');191style.textContent = SHADOW_ROOT_CSS;192this.shadowRoot.appendChild(style);193this.shadowRoot.appendChild(this.view);194this.shadowRoot.appendChild(DOM.$('slot'));195} else {196this.container.appendChild(this.view);197}198199const toDisposeOnSetContainer = new DisposableStore();200201ContextView.BUBBLE_UP_EVENTS.forEach(event => {202toDisposeOnSetContainer.add(DOM.addStandardDisposableListener(this.container!, event, e => {203this.onDOMEvent(e, false);204}));205});206207ContextView.BUBBLE_DOWN_EVENTS.forEach(event => {208toDisposeOnSetContainer.add(DOM.addStandardDisposableListener(this.container!, event, e => {209this.onDOMEvent(e, true);210}, true));211});212213this.toDisposeOnSetContainer = toDisposeOnSetContainer;214}215}216217show(delegate: IDelegate): void {218if (this.isVisible()) {219this.hide();220}221222// Show static box223DOM.clearNode(this.view);224this.view.className = 'context-view monaco-component';225this.view.style.top = '0px';226this.view.style.left = '0px';227this.view.style.zIndex = `${2575 + (delegate.layer ?? 0)}`;228this.view.style.position = this.useFixedPosition ? 'fixed' : 'absolute';229DOM.show(this.view);230231// Render content232this.toDisposeOnClean = delegate.render(this.view) || Disposable.None;233234// Set active delegate235this.delegate = delegate;236237// Layout238this.doLayout();239240// Focus241this.delegate.focus?.();242}243244getViewElement(): HTMLElement {245return this.view;246}247248layout(): void {249if (!this.isVisible()) {250return;251}252253if (this.delegate!.canRelayout === false && !(platform.isIOS && BrowserFeatures.pointerEvents)) {254this.hide();255return;256}257258this.delegate?.layout?.();259260this.doLayout();261}262263private doLayout(): void {264// Check that we still have a delegate - this.delegate.layout may have hidden265if (!this.isVisible()) {266return;267}268269// Get anchor270const anchor = this.delegate!.getAnchor();271272// Compute around273let around: IView;274275// Get the element's position and size (to anchor the view)276if (DOM.isHTMLElement(anchor)) {277const elementPosition = DOM.getDomNodePagePosition(anchor);278279// In areas where zoom is applied to the element or its ancestors, we need to adjust the size of the element280// e.g. The title bar has counter zoom behavior meaning it applies the inverse of zoom level.281// Window Zoom Level: 1.5, Title Bar Zoom: 1/1.5, Size Multiplier: 1.5282const zoom = DOM.getDomNodeZoomLevel(anchor);283284around = {285top: elementPosition.top * zoom,286left: elementPosition.left * zoom,287width: elementPosition.width * zoom,288height: elementPosition.height * zoom289};290} else if (isAnchor(anchor)) {291around = {292top: anchor.y,293left: anchor.x,294width: anchor.width || 1,295height: anchor.height || 2296};297} else {298around = {299top: anchor.posy,300left: anchor.posx,301// We are about to position the context view where the mouse302// cursor is. To prevent the view being exactly under the mouse303// when showing and thus potentially triggering an action within,304// we treat the mouse location like a small sized block element.305width: 2,306height: 2307};308}309310const viewSizeWidth = DOM.getTotalWidth(this.view);311const viewSizeHeight = DOM.getTotalHeight(this.view);312313const anchorPosition = this.delegate!.anchorPosition ?? AnchorPosition.BELOW;314const anchorAlignment = this.delegate!.anchorAlignment ?? AnchorAlignment.LEFT;315const anchorAxisAlignment = this.delegate!.anchorAxisAlignment ?? AnchorAxisAlignment.VERTICAL;316317let top: number;318let left: number;319320const activeWindow = DOM.getActiveWindow();321if (anchorAxisAlignment === AnchorAxisAlignment.VERTICAL) {322const verticalAnchor: ILayoutAnchor = { offset: around.top - activeWindow.pageYOffset, size: around.height, position: anchorPosition === AnchorPosition.BELOW ? LayoutAnchorPosition.Before : LayoutAnchorPosition.After };323const horizontalAnchor: ILayoutAnchor = { offset: around.left, size: around.width, position: anchorAlignment === AnchorAlignment.LEFT ? LayoutAnchorPosition.Before : LayoutAnchorPosition.After, mode: LayoutAnchorMode.ALIGN };324325top = layout(activeWindow.innerHeight, viewSizeHeight, verticalAnchor) + activeWindow.pageYOffset;326327// if view intersects vertically with anchor, we must avoid the anchor328if (Range.intersects({ start: top, end: top + viewSizeHeight }, { start: verticalAnchor.offset, end: verticalAnchor.offset + verticalAnchor.size })) {329horizontalAnchor.mode = LayoutAnchorMode.AVOID;330}331332left = layout(activeWindow.innerWidth, viewSizeWidth, horizontalAnchor);333} else {334const horizontalAnchor: ILayoutAnchor = { offset: around.left, size: around.width, position: anchorAlignment === AnchorAlignment.LEFT ? LayoutAnchorPosition.Before : LayoutAnchorPosition.After };335const verticalAnchor: ILayoutAnchor = { offset: around.top, size: around.height, position: anchorPosition === AnchorPosition.BELOW ? LayoutAnchorPosition.Before : LayoutAnchorPosition.After, mode: LayoutAnchorMode.ALIGN };336337left = layout(activeWindow.innerWidth, viewSizeWidth, horizontalAnchor);338339// if view intersects horizontally with anchor, we must avoid the anchor340if (Range.intersects({ start: left, end: left + viewSizeWidth }, { start: horizontalAnchor.offset, end: horizontalAnchor.offset + horizontalAnchor.size })) {341verticalAnchor.mode = LayoutAnchorMode.AVOID;342}343344top = layout(activeWindow.innerHeight, viewSizeHeight, verticalAnchor) + activeWindow.pageYOffset;345}346347this.view.classList.remove('top', 'bottom', 'left', 'right');348this.view.classList.add(anchorPosition === AnchorPosition.BELOW ? 'bottom' : 'top');349this.view.classList.add(anchorAlignment === AnchorAlignment.LEFT ? 'left' : 'right');350this.view.classList.toggle('fixed', this.useFixedPosition);351352const containerPosition = DOM.getDomNodePagePosition(this.container!);353354// Account for container scroll when positioning the context view355const containerScrollTop = this.container!.scrollTop || 0;356const containerScrollLeft = this.container!.scrollLeft || 0;357358this.view.style.top = `${top - (this.useFixedPosition ? DOM.getDomNodePagePosition(this.view).top : containerPosition.top) + containerScrollTop}px`;359this.view.style.left = `${left - (this.useFixedPosition ? DOM.getDomNodePagePosition(this.view).left : containerPosition.left) + containerScrollLeft}px`;360this.view.style.width = 'initial';361}362363hide(data?: unknown): void {364const delegate = this.delegate;365this.delegate = null;366367if (delegate?.onHide) {368delegate.onHide(data);369}370371this.toDisposeOnClean.dispose();372373DOM.hide(this.view);374}375376private isVisible(): boolean {377return !!this.delegate;378}379380private onDOMEvent(e: UIEvent, onCapture: boolean): void {381if (this.delegate) {382if (this.delegate.onDOMEvent) {383this.delegate.onDOMEvent(e, <HTMLElement>DOM.getWindow(e).document.activeElement);384} else if (onCapture && !DOM.isAncestor(<HTMLElement>e.target, this.container)) {385this.hide();386}387}388}389390override dispose(): void {391this.hide();392393super.dispose();394}395}396397const SHADOW_ROOT_CSS = /* css */ `398:host {399all: initial; /* 1st rule so subsequent properties are reset. */400}401402.codicon[class*='codicon-'] {403font: normal normal normal 16px/1 codicon;404display: inline-block;405text-decoration: none;406text-rendering: auto;407text-align: center;408-webkit-font-smoothing: antialiased;409-moz-osx-font-smoothing: grayscale;410user-select: none;411-webkit-user-select: none;412-ms-user-select: none;413}414415:host {416font-family: -apple-system, BlinkMacSystemFont, "Segoe WPC", "Segoe UI", "HelveticaNeue-Light", system-ui, "Ubuntu", "Droid Sans", sans-serif;417}418419:host-context(.mac) { font-family: -apple-system, BlinkMacSystemFont, sans-serif; }420:host-context(.mac:lang(zh-Hans)) { font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Hiragino Sans GB", sans-serif; }421:host-context(.mac:lang(zh-Hant)) { font-family: -apple-system, BlinkMacSystemFont, "PingFang TC", sans-serif; }422:host-context(.mac:lang(ja)) { font-family: -apple-system, BlinkMacSystemFont, "Hiragino Kaku Gothic Pro", sans-serif; }423:host-context(.mac:lang(ko)) { font-family: -apple-system, BlinkMacSystemFont, "Apple SD Gothic Neo", "Nanum Gothic", "AppleGothic", sans-serif; }424425:host-context(.windows) { font-family: "Segoe WPC", "Segoe UI", sans-serif; }426:host-context(.windows:lang(zh-Hans)) { font-family: "Segoe WPC", "Segoe UI", "Microsoft YaHei", sans-serif; }427:host-context(.windows:lang(zh-Hant)) { font-family: "Segoe WPC", "Segoe UI", "Microsoft Jhenghei", sans-serif; }428:host-context(.windows:lang(ja)) { font-family: "Segoe WPC", "Segoe UI", "Yu Gothic UI", "Meiryo UI", sans-serif; }429:host-context(.windows:lang(ko)) { font-family: "Segoe WPC", "Segoe UI", "Malgun Gothic", "Dotom", sans-serif; }430431:host-context(.linux) { font-family: system-ui, "Ubuntu", "Droid Sans", sans-serif; }432:host-context(.linux:lang(zh-Hans)) { font-family: system-ui, "Ubuntu", "Droid Sans", "Source Han Sans SC", "Source Han Sans CN", "Source Han Sans", sans-serif; }433:host-context(.linux:lang(zh-Hant)) { font-family: system-ui, "Ubuntu", "Droid Sans", "Source Han Sans TC", "Source Han Sans TW", "Source Han Sans", sans-serif; }434:host-context(.linux:lang(ja)) { font-family: system-ui, "Ubuntu", "Droid Sans", "Source Han Sans J", "Source Han Sans JP", "Source Han Sans", sans-serif; }435:host-context(.linux:lang(ko)) { font-family: system-ui, "Ubuntu", "Droid Sans", "Source Han Sans K", "Source Han Sans JR", "Source Han Sans", "UnDotum", "FBaekmuk Gulim", sans-serif; }436`;437438439