Path: blob/main/src/vs/base/browser/ui/contextview/contextview.ts
5222 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}127128129if (viewSize <= viewportSize - layoutAfterAnchorBoundary && layoutBeforeAnchorBoundary < viewSize / 2) {130return layoutAfterAnchorBoundary; // ok case, lay it out after the anchor131}132133134return 0; // sad case, lay it over the anchor135}136}137138export class ContextView extends Disposable {139140private static readonly BUBBLE_UP_EVENTS = ['click', 'keydown', 'focus', 'blur'];141private static readonly BUBBLE_DOWN_EVENTS = ['click'];142143private container: HTMLElement | null = null;144private view: HTMLElement;145private useFixedPosition = false;146private useShadowDOM = false;147private delegate: IDelegate | null = null;148private toDisposeOnClean: IDisposable = Disposable.None;149private toDisposeOnSetContainer: IDisposable = Disposable.None;150private shadowRoot: ShadowRoot | null = null;151private shadowRootHostElement: HTMLElement | null = null;152153constructor(container: HTMLElement, domPosition: ContextViewDOMPosition) {154super();155156this.view = DOM.$('.context-view');157DOM.hide(this.view);158159this.setContainer(container, domPosition);160this._register(toDisposable(() => this.setContainer(null, ContextViewDOMPosition.ABSOLUTE)));161}162163setContainer(container: HTMLElement | null, domPosition: ContextViewDOMPosition): void {164this.useFixedPosition = domPosition !== ContextViewDOMPosition.ABSOLUTE;165const usedShadowDOM = this.useShadowDOM;166this.useShadowDOM = domPosition === ContextViewDOMPosition.FIXED_SHADOW;167168if (container === this.container && usedShadowDOM === this.useShadowDOM) {169return; // container is the same and no shadow DOM usage has changed170}171172if (this.container) {173this.toDisposeOnSetContainer.dispose();174175this.view.remove();176if (this.shadowRoot) {177this.shadowRoot = null;178this.shadowRootHostElement?.remove();179this.shadowRootHostElement = null;180}181182this.container = null;183}184185if (container) {186this.container = container;187188if (this.useShadowDOM) {189this.shadowRootHostElement = DOM.$('.shadow-root-host');190this.container.appendChild(this.shadowRootHostElement);191this.shadowRoot = this.shadowRootHostElement.attachShadow({ mode: 'open' });192const style = document.createElement('style');193style.textContent = SHADOW_ROOT_CSS;194this.shadowRoot.appendChild(style);195this.shadowRoot.appendChild(this.view);196this.shadowRoot.appendChild(DOM.$('slot'));197} else {198this.container.appendChild(this.view);199}200201const toDisposeOnSetContainer = new DisposableStore();202203ContextView.BUBBLE_UP_EVENTS.forEach(event => {204toDisposeOnSetContainer.add(DOM.addStandardDisposableListener(this.container!, event, e => {205this.onDOMEvent(e, false);206}));207});208209ContextView.BUBBLE_DOWN_EVENTS.forEach(event => {210toDisposeOnSetContainer.add(DOM.addStandardDisposableListener(this.container!, event, e => {211this.onDOMEvent(e, true);212}, true));213});214215this.toDisposeOnSetContainer = toDisposeOnSetContainer;216}217}218219show(delegate: IDelegate): void {220if (this.isVisible()) {221this.hide();222}223224// Show static box225DOM.clearNode(this.view);226this.view.className = 'context-view monaco-component';227this.view.style.top = '0px';228this.view.style.left = '0px';229this.view.style.zIndex = `${2575 + (delegate.layer ?? 0)}`;230this.view.style.position = this.useFixedPosition ? 'fixed' : 'absolute';231DOM.show(this.view);232233// Render content234this.toDisposeOnClean = delegate.render(this.view) || Disposable.None;235236// Set active delegate237this.delegate = delegate;238239// Layout240this.doLayout();241242// Focus243this.delegate.focus?.();244}245246getViewElement(): HTMLElement {247return this.view;248}249250layout(): void {251if (!this.isVisible()) {252return;253}254255if (this.delegate!.canRelayout === false && !(platform.isIOS && BrowserFeatures.pointerEvents)) {256this.hide();257return;258}259260this.delegate?.layout?.();261262this.doLayout();263}264265private doLayout(): void {266// Check that we still have a delegate - this.delegate.layout may have hidden267if (!this.isVisible()) {268return;269}270271// Get anchor272const anchor = this.delegate!.getAnchor();273274// Compute around275let around: IView;276277// Get the element's position and size (to anchor the view)278if (DOM.isHTMLElement(anchor)) {279const elementPosition = DOM.getDomNodePagePosition(anchor);280281// In areas where zoom is applied to the element or its ancestors, we need to adjust the size of the element282// e.g. The title bar has counter zoom behavior meaning it applies the inverse of zoom level.283// Window Zoom Level: 1.5, Title Bar Zoom: 1/1.5, Size Multiplier: 1.5284const zoom = DOM.getDomNodeZoomLevel(anchor);285286around = {287top: elementPosition.top * zoom,288left: elementPosition.left * zoom,289width: elementPosition.width * zoom,290height: elementPosition.height * zoom291};292} else if (isAnchor(anchor)) {293around = {294top: anchor.y,295left: anchor.x,296width: anchor.width || 1,297height: anchor.height || 2298};299} else {300around = {301top: anchor.posy,302left: anchor.posx,303// We are about to position the context view where the mouse304// cursor is. To prevent the view being exactly under the mouse305// when showing and thus potentially triggering an action within,306// we treat the mouse location like a small sized block element.307width: 2,308height: 2309};310}311312const viewSizeWidth = DOM.getTotalWidth(this.view);313const viewSizeHeight = DOM.getTotalHeight(this.view);314315const anchorPosition = this.delegate!.anchorPosition ?? AnchorPosition.BELOW;316const anchorAlignment = this.delegate!.anchorAlignment ?? AnchorAlignment.LEFT;317const anchorAxisAlignment = this.delegate!.anchorAxisAlignment ?? AnchorAxisAlignment.VERTICAL;318319let top: number;320let left: number;321322const activeWindow = DOM.getActiveWindow();323if (anchorAxisAlignment === AnchorAxisAlignment.VERTICAL) {324const verticalAnchor: ILayoutAnchor = { offset: around.top - activeWindow.pageYOffset, size: around.height, position: anchorPosition === AnchorPosition.BELOW ? LayoutAnchorPosition.Before : LayoutAnchorPosition.After };325const horizontalAnchor: ILayoutAnchor = { offset: around.left, size: around.width, position: anchorAlignment === AnchorAlignment.LEFT ? LayoutAnchorPosition.Before : LayoutAnchorPosition.After, mode: LayoutAnchorMode.ALIGN };326327top = layout(activeWindow.innerHeight, viewSizeHeight, verticalAnchor) + activeWindow.pageYOffset;328329// if view intersects vertically with anchor, we must avoid the anchor330if (Range.intersects({ start: top, end: top + viewSizeHeight }, { start: verticalAnchor.offset, end: verticalAnchor.offset + verticalAnchor.size })) {331horizontalAnchor.mode = LayoutAnchorMode.AVOID;332}333334left = layout(activeWindow.innerWidth, viewSizeWidth, horizontalAnchor);335} else {336const horizontalAnchor: ILayoutAnchor = { offset: around.left, size: around.width, position: anchorAlignment === AnchorAlignment.LEFT ? LayoutAnchorPosition.Before : LayoutAnchorPosition.After };337const verticalAnchor: ILayoutAnchor = { offset: around.top, size: around.height, position: anchorPosition === AnchorPosition.BELOW ? LayoutAnchorPosition.Before : LayoutAnchorPosition.After, mode: LayoutAnchorMode.ALIGN };338339left = layout(activeWindow.innerWidth, viewSizeWidth, horizontalAnchor);340341// if view intersects horizontally with anchor, we must avoid the anchor342if (Range.intersects({ start: left, end: left + viewSizeWidth }, { start: horizontalAnchor.offset, end: horizontalAnchor.offset + horizontalAnchor.size })) {343verticalAnchor.mode = LayoutAnchorMode.AVOID;344}345346top = layout(activeWindow.innerHeight, viewSizeHeight, verticalAnchor) + activeWindow.pageYOffset;347}348349this.view.classList.remove('top', 'bottom', 'left', 'right');350this.view.classList.add(anchorPosition === AnchorPosition.BELOW ? 'bottom' : 'top');351this.view.classList.add(anchorAlignment === AnchorAlignment.LEFT ? 'left' : 'right');352this.view.classList.toggle('fixed', this.useFixedPosition);353354const containerPosition = DOM.getDomNodePagePosition(this.container!);355356// Account for container scroll when positioning the context view357const containerScrollTop = this.container!.scrollTop || 0;358const containerScrollLeft = this.container!.scrollLeft || 0;359360this.view.style.top = `${top - (this.useFixedPosition ? DOM.getDomNodePagePosition(this.view).top : containerPosition.top) + containerScrollTop}px`;361this.view.style.left = `${left - (this.useFixedPosition ? DOM.getDomNodePagePosition(this.view).left : containerPosition.left) + containerScrollLeft}px`;362this.view.style.width = 'initial';363}364365hide(data?: unknown): void {366const delegate = this.delegate;367this.delegate = null;368369if (delegate?.onHide) {370delegate.onHide(data);371}372373this.toDisposeOnClean.dispose();374375DOM.hide(this.view);376}377378private isVisible(): boolean {379return !!this.delegate;380}381382private onDOMEvent(e: UIEvent, onCapture: boolean): void {383if (this.delegate) {384if (this.delegate.onDOMEvent) {385this.delegate.onDOMEvent(e, <HTMLElement>DOM.getWindow(e).document.activeElement);386} else if (onCapture && !DOM.isAncestor(<HTMLElement>e.target, this.container)) {387this.hide();388}389}390}391392override dispose(): void {393this.hide();394395super.dispose();396}397}398399const SHADOW_ROOT_CSS = /* css */ `400:host {401all: initial; /* 1st rule so subsequent properties are reset. */402}403404.codicon[class*='codicon-'] {405font: normal normal normal 16px/1 codicon;406display: inline-block;407text-decoration: none;408text-rendering: auto;409text-align: center;410-webkit-font-smoothing: antialiased;411-moz-osx-font-smoothing: grayscale;412user-select: none;413-webkit-user-select: none;414-ms-user-select: none;415}416417:host {418font-family: -apple-system, BlinkMacSystemFont, "Segoe WPC", "Segoe UI", "HelveticaNeue-Light", system-ui, "Ubuntu", "Droid Sans", sans-serif;419}420421:host-context(.mac) { font-family: -apple-system, BlinkMacSystemFont, sans-serif; }422:host-context(.mac:lang(zh-Hans)) { font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Hiragino Sans GB", sans-serif; }423:host-context(.mac:lang(zh-Hant)) { font-family: -apple-system, BlinkMacSystemFont, "PingFang TC", sans-serif; }424:host-context(.mac:lang(ja)) { font-family: -apple-system, BlinkMacSystemFont, "Hiragino Kaku Gothic Pro", sans-serif; }425:host-context(.mac:lang(ko)) { font-family: -apple-system, BlinkMacSystemFont, "Apple SD Gothic Neo", "Nanum Gothic", "AppleGothic", sans-serif; }426427:host-context(.windows) { font-family: "Segoe WPC", "Segoe UI", sans-serif; }428:host-context(.windows:lang(zh-Hans)) { font-family: "Segoe WPC", "Segoe UI", "Microsoft YaHei", sans-serif; }429:host-context(.windows:lang(zh-Hant)) { font-family: "Segoe WPC", "Segoe UI", "Microsoft Jhenghei", sans-serif; }430:host-context(.windows:lang(ja)) { font-family: "Segoe WPC", "Segoe UI", "Yu Gothic UI", "Meiryo UI", sans-serif; }431:host-context(.windows:lang(ko)) { font-family: "Segoe WPC", "Segoe UI", "Malgun Gothic", "Dotom", sans-serif; }432433:host-context(.linux) { font-family: system-ui, "Ubuntu", "Droid Sans", sans-serif; }434: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; }435: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; }436: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; }437: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; }438`;439440441