feat: manually mirror opencoze's code from bytedance

Change-Id: I09a73aadda978ad9511264a756b2ce51f5761adf
This commit is contained in:
fanlv
2025-07-20 17:36:12 +08:00
commit 890153324f
14811 changed files with 1923430 additions and 0 deletions

View File

@@ -0,0 +1,129 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useCallback } from 'react';
import { clsx } from 'clsx';
import {
LabelService,
OpenerService,
type URI,
useIDEService,
} from '@coze-project-ide/core';
import { codicon } from '../../utils';
import { type ActivityBarItem, LayoutPanelType } from '../../types/view';
import { HoverService } from '../../services/hover-service';
import { useCurrentWidgetFromArea } from '../../hooks';
import { useStyling } from './styles';
interface ActivityBarProps {
list: ActivityBarItem[];
currentUri?: URI;
setCurrentUri: (uri: URI) => void;
}
/**
* activitybar 有两种状态
* - 选中态 select 同时高亮和左侧有蓝色竖线
* - 激活态 active 仅高亮
*/
export const ActivityBar: React.FC<ActivityBarProps> = ({
list,
currentUri,
setCurrentUri,
}) => {
const labelService = useIDEService<LabelService>(LabelService);
const hoverService = useIDEService<HoverService>(HoverService);
const openerService = useIDEService<OpenerService>(OpenerService);
const mainPanelUri = useCurrentWidgetFromArea(
LayoutPanelType.MAIN_PANEL,
)?.uri;
const renderIcon = (item: ActivityBarItem) => {
const icon = labelService.getIcon(item.uri);
if (typeof icon !== 'string') {
return icon;
}
return <i className={codicon(icon)} />;
};
const handleItemClick = async (
e: React.MouseEvent<HTMLDivElement, MouseEvent>,
item: ActivityBarItem,
) => {
if (item?.onClick) {
item.onClick(e);
} else if (item.position === 'top') {
setCurrentUri(item.uri);
} else {
openerService.open(item.uri);
}
hoverService.cancelHover();
};
const handleTooltip = useCallback(
(e: React.MouseEvent<HTMLDivElement, MouseEvent>, content?: string) => {
if (!content) {
return;
}
hoverService.requestHover({
content,
target: e.currentTarget,
position: 'right',
});
},
[],
);
const renderListItem = (item: ActivityBarItem) => {
const title = labelService.getName(item.uri);
const isSelect = currentUri && item.uri.isEqualOrParent(currentUri);
const isActive = mainPanelUri && item.uri.isEqualOrParent(mainPanelUri);
return (
<div
key={title}
className={clsx(
'item-container',
isSelect && 'selected',
isActive && 'active',
)}
onClick={e => handleItemClick(e, item)}
onMouseEnter={e => !isSelect && handleTooltip(e, item.tooltip)}
>
{renderIcon(item)}
</div>
);
};
useStyling();
return (
<div className="activity-bar-widget-container">
<div className="top-container">
{list
.filter(item => item.position === 'top')
.map(item => renderListItem(item))}
</div>
<div className="bottom-container">
{list
.filter(item => item.position === 'bottom')
.map(item => renderListItem(item))}
</div>
</div>
);
};

View File

@@ -0,0 +1,75 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useStyling as useStylingCore } from '@coze-project-ide/core';
export const useStyling = () => {
useStylingCore(
'flowide-activity-bar-widget',
(_, { getColor }) => `
.activity-bar-widget-container {
display: flex;
flex-direction: column;
height: 100%;
justify-content: space-between;
.top-container, .bottom-container {
display: flex;
flex-direction: column;
}
.item-container {
cursor: pointer;
position: relative;
color: ${getColor('flowide.color.base.text.2')};
}
.item-container.active {
color: ${getColor('flowide.color.base.text.0')};
}
.item-container.selected {
color: ${getColor('flowide.color.base.text.0')};
}
.item-container.selected::before {
content: "";
position: absolute;
width: 2px;
height: 100%;
background: ${getColor('flowide.color.base.primary')};
}
.item-container:hover {
color: ${getColor('flowide.color.base.text.0')};
}
.item-container > i {
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
text-align: center;
color: inherit;
width: 36px;
height: 36px;
mask-repeat: no-repeat;
-webkit-mask-repeat: no-repeat;
mask-size: 24px;
-webkit-mask-size: 24px;
mask-position: 50% 50%;
-webkit-mask-position: 50% 50%;
}
}`,
);
};

View File

@@ -0,0 +1,35 @@
import updateGeometry from '../update-geometry';
const clickRail = e => {
const { element } = e;
e.event.bind(e.scrollbarYRail, 'mousedown', e => {
const positionTop =
e.pageY -
window.pageYOffset -
e.scrollbarYRail.getBoundingClientRect().top;
const direction = positionTop > e.scrollbarYTop ? 1 : -1;
e.element.scrollTop += direction * e.containerHeight;
updateGeometry(i);
e.stopPropagation();
});
e.event.bind(e.scrollbarY, 'mousedown', e => e.stopPropagation());
e.event.bind(e.scrollbarX, 'mousedown', e => e.stopPropagation());
e.event.bind(e.scrollbarXRail, 'mousedown', e => {
const left =
e.pageX -
window.pageXOffset -
e.scrollbarXRail.getBoundingClientRect().left;
const direction = left > e.scrollbarXLeft ? 1 : -1;
e.element.scrollLeft += direction * e.containerWidth;
updateGeometry(i);
e.stopPropagation();
});
};
export default clickRail;

View File

@@ -0,0 +1,104 @@
import updateGeometry from '../update-geometry';
import { toInt } from '../lib/util';
import * as DOM from '../lib/dom';
import * as CSS from '../lib/css';
import cls, {
addScrollingClass,
removeScrollingClass,
} from '../lib/class-names';
export default function (i) {
bindMouseScrollHandler(i, [
'containerWidth',
'contentWidth',
'pageX',
'railXWidth',
'scrollbarX',
'scrollbarXWidth',
'scrollLeft',
'x',
'scrollbarXRail',
]);
bindMouseScrollHandler(i, [
'containerHeight',
'contentHeight',
'pageY',
'railYHeight',
'scrollbarY',
'scrollbarYHeight',
'scrollTop',
'y',
'scrollbarYRail',
]);
}
function bindMouseScrollHandler(
i,
[
containerHeight,
contentHeight,
pageY,
railYHeight,
scrollbarY,
scrollbarYHeight,
scrollTop,
y,
scrollbarYRail,
],
) {
const { element } = i;
let startingScrollTop = null;
let startingMousePageY = null;
let scrollBy = null;
function mouseMoveHandler(e) {
if (e.touches && e.touches[0]) {
e[pageY] = e.touches[0].pageY;
}
element[scrollTop] =
startingScrollTop + scrollBy * (e[pageY] - startingMousePageY);
addScrollingClass(i, y);
updateGeometry(i);
e.stopPropagation();
if (e.type.startsWith('touch') && e.changedTouches.length > 1) {
e.preventDefault();
}
}
function mouseUpHandler() {
removeScrollingClass(i, y);
i[scrollbarYRail].classList.remove(cls.state.clicking);
i.event.unbind(i.ownerDocument, 'mousemove', mouseMoveHandler);
}
function bindMoves(e, touchMode) {
startingScrollTop = element[scrollTop];
if (touchMode && e.touches) {
e[pageY] = e.touches[0].pageY;
}
startingMousePageY = e[pageY];
scrollBy =
(i[contentHeight] - i[containerHeight]) /
(i[railYHeight] - i[scrollbarYHeight]);
if (!touchMode) {
i.event.bind(i.ownerDocument, 'mousemove', mouseMoveHandler);
i.event.once(i.ownerDocument, 'mouseup', mouseUpHandler);
e.preventDefault();
} else {
i.event.bind(i.ownerDocument, 'touchmove', mouseMoveHandler);
}
i[scrollbarYRail].classList.add(cls.state.clicking);
e.stopPropagation();
}
i.event.bind(i[scrollbarY], 'mousedown', e => {
bindMoves(e);
});
i.event.bind(i[scrollbarY], 'touchstart', e => {
bindMoves(e, true);
});
}

View File

@@ -0,0 +1,150 @@
import updateGeometry from '../update-geometry';
import { isEditable } from '../lib/util';
import * as DOM from '../lib/dom';
export default function (i) {
const { element } = i;
function preventDefault(x, y) {
const scrollTop = Math.floor(element.scrollTop);
if (x === 0) {
if (!i.scrollbarYActive) {
return false;
}
if (
(scrollTop === 0 && y > 0) ||
(scrollTop >= i.contentHeight - i.containerHeight && y < 0)
) {
return !i.settings.wheelPropagation;
}
}
const { scrollLeft } = element;
if (y === 0) {
if (!i.scrollbarXActive) {
return false;
}
if (
(scrollLeft === 0 && x < 0) ||
(scrollLeft >= i.contentWidth - i.containerWidth && x > 0)
) {
return !i.settings.wheelPropagation;
}
}
return true;
}
i.event.bind(i.ownerDocument, 'keydown', e => {
if (
(e.isDefaultPrevented && e.isDefaultPrevented()) ||
e.defaultPrevented
) {
return;
}
if (
!DOM.matches(element, ':hover') &&
!(
DOM.matches(i.scrollbarX, ':focus') ||
DOM.matches(i.scrollbarY, ':focus')
)
) {
return;
}
let activeElement = document.activeElement
? document.activeElement
: i.ownerDocument.activeElement;
if (activeElement) {
if (activeElement.tagName === 'IFRAME') {
activeElement = activeElement.contentDocument.activeElement;
} else {
// go deeper if element is a webcomponent
while (activeElement.shadowRoot) {
activeElement = activeElement.shadowRoot.activeElement;
}
}
if (isEditable(activeElement)) {
return;
}
}
let deltaX = 0;
let deltaY = 0;
switch (e.which) {
case 37: // left
if (e.metaKey) {
deltaX = -i.contentWidth;
} else if (e.altKey) {
deltaX = -i.containerWidth;
} else {
deltaX = -30;
}
break;
case 38: // up
if (e.metaKey) {
deltaY = i.contentHeight;
} else if (e.altKey) {
deltaY = i.containerHeight;
} else {
deltaY = 30;
}
break;
case 39: // right
if (e.metaKey) {
deltaX = i.contentWidth;
} else if (e.altKey) {
deltaX = i.containerWidth;
} else {
deltaX = 30;
}
break;
case 40: // down
if (e.metaKey) {
deltaY = -i.contentHeight;
} else if (e.altKey) {
deltaY = -i.containerHeight;
} else {
deltaY = -30;
}
break;
case 32: // space bar
if (e.shiftKey) {
deltaY = i.containerHeight;
} else {
deltaY = -i.containerHeight;
}
break;
case 33: // page up
deltaY = i.containerHeight;
break;
case 34: // page down
deltaY = -i.containerHeight;
break;
case 36: // home
deltaY = i.contentHeight;
break;
case 35: // end
deltaY = -i.contentHeight;
break;
default:
return;
}
if (i.settings.suppressScrollX && deltaX !== 0) {
return;
}
if (i.settings.suppressScrollY && deltaY !== 0) {
return;
}
element.scrollTop -= deltaY;
element.scrollLeft += deltaX;
updateGeometry(i);
if (preventDefault(deltaX, deltaY)) {
e.preventDefault();
}
});
}

View File

@@ -0,0 +1,165 @@
import updateGeometry from '../update-geometry';
import { env } from '../lib/util';
import * as CSS from '../lib/css';
import cls from '../lib/class-names';
export default function (i) {
const { element } = i;
const shouldPrevent = false;
function shouldPreventDefault(deltaX, deltaY) {
const roundedScrollTop = Math.floor(element.scrollTop);
const isTop = element.scrollTop === 0;
const isBottom =
roundedScrollTop + element.offsetHeight === element.scrollHeight;
const isLeft = element.scrollLeft === 0;
const isRight =
element.scrollLeft + element.offsetWidth === element.scrollWidth;
let hitsBound;
// pick axis with primary direction
if (Math.abs(deltaY) > Math.abs(deltaX)) {
hitsBound = isTop || isBottom;
} else {
hitsBound = isLeft || isRight;
}
return hitsBound ? !i.settings.wheelPropagation : true;
}
function getDeltaFromEvent(e) {
let { deltaX } = e;
let deltaY = -1 * e.deltaY;
if (typeof deltaX === 'undefined' || typeof deltaY === 'undefined') {
// OS X Safari
deltaX = (-1 * e.wheelDeltaX) / 6;
deltaY = e.wheelDeltaY / 6;
}
if (e.deltaMode && e.deltaMode === 1) {
// Firefox in deltaMode 1: Line scrolling
deltaX *= 10;
deltaY *= 10;
}
if (deltaX !== deltaX && deltaY !== deltaY /* NaN checks */) {
// IE in some mouse drivers
deltaX = 0;
deltaY = e.wheelDelta;
}
if (e.shiftKey) {
// reverse axis with shift key
return [-deltaY, -deltaX];
}
if (Math.abs(deltaY) > Math.abs(deltaX)) {
deltaX = 0;
}
if (Math.abs(deltaX) > Math.abs(deltaY)) {
deltaY = 0;
}
return [deltaX, deltaY];
}
function shouldBeConsumedByChild(target, deltaX, deltaY) {
// FIXME: this is a workaround for <select> issue in FF and IE #571
if (!env.isWebKit && element.querySelector('select:focus')) {
return true;
}
if (!element.contains(target)) {
return false;
}
let cursor = target;
while (cursor && cursor !== element) {
if (cursor.classList.contains(cls.element.consuming)) {
return true;
}
const style = CSS.get(cursor);
// if deltaY && vertical scrollable
if (deltaY && style.overflowY.match(/(scroll|auto)/)) {
const maxScrollTop = cursor.scrollHeight - cursor.clientHeight;
if (maxScrollTop > 0) {
if (
(cursor.scrollTop > 0 && deltaY < 0) ||
(cursor.scrollTop < maxScrollTop && deltaY > 0)
) {
return true;
}
}
}
// if deltaX && horizontal scrollable
if (deltaX && style.overflowX.match(/(scroll|auto)/)) {
const maxScrollLeft = cursor.scrollWidth - cursor.clientWidth;
if (maxScrollLeft > 0) {
if (
(cursor.scrollLeft > 0 && deltaX < 0) ||
(cursor.scrollLeft < maxScrollLeft && deltaX > 0)
) {
return true;
}
}
}
cursor = cursor.parentNode;
}
return false;
}
function mousewheelHandler(e) {
const [deltaX, deltaY] = getDeltaFromEvent(e);
// FIXME: mousewheel 滚动的时候有问题
if (shouldBeConsumedByChild(e.target, deltaX, deltaY)) {
return;
}
let shouldPrevent = false;
if (!i.settings.useBothWheelAxes) {
// deltaX will only be used for horizontal scrolling and deltaY will
// only be used for vertical scrolling - this is the default
element.scrollTop -= deltaY * i.settings.wheelSpeed;
element.scrollLeft += deltaX * i.settings.wheelSpeed;
} else if (i.scrollbarYActive && !i.scrollbarXActive) {
// only vertical scrollbar is active and useBothWheelAxes option is
// active, so let's scroll vertical bar using both mouse wheel axes
if (deltaY) {
element.scrollTop -= deltaY * i.settings.wheelSpeed;
} else {
element.scrollTop += deltaX * i.settings.wheelSpeed;
}
shouldPrevent = true;
} else if (i.scrollbarXActive && !i.scrollbarYActive) {
// useBothWheelAxes and only horizontal bar is active, so use both
// wheel axes for horizontal bar
if (deltaX) {
element.scrollLeft += deltaX * i.settings.wheelSpeed;
} else {
element.scrollLeft -= deltaY * i.settings.wheelSpeed;
}
shouldPrevent = true;
}
updateGeometry(i);
shouldPrevent = shouldPrevent || shouldPreventDefault(deltaX, deltaY);
if (shouldPrevent && !e.ctrlKey) {
e.stopPropagation();
e.preventDefault();
}
}
if (typeof window.onwheel !== 'undefined') {
i.event.bind(element, 'wheel', mousewheelHandler);
} else if (typeof window.onmousewheel !== 'undefined') {
i.event.bind(element, 'mousewheel', mousewheelHandler);
}
}

View File

@@ -0,0 +1,220 @@
import updateGeometry from '../update-geometry';
import { env } from '../lib/util';
import * as CSS from '../lib/css';
import cls from '../lib/class-names';
export default function (i) {
if (!env.supportsTouch && !env.supportsIePointer) {
return;
}
const { element } = i;
function shouldPrevent(deltaX, deltaY) {
const scrollTop = Math.floor(element.scrollTop);
const { scrollLeft } = element;
const magnitudeX = Math.abs(deltaX);
const magnitudeY = Math.abs(deltaY);
if (magnitudeY > magnitudeX) {
// user is perhaps trying to swipe up/down the page
if (
(deltaY < 0 && scrollTop === i.contentHeight - i.containerHeight) ||
(deltaY > 0 && scrollTop === 0)
) {
// set prevent for mobile Chrome refresh
return window.scrollY === 0 && deltaY > 0 && env.isChrome;
}
} else if (magnitudeX > magnitudeY) {
// user is perhaps trying to swipe left/right across the page
if (
(deltaX < 0 && scrollLeft === i.contentWidth - i.containerWidth) ||
(deltaX > 0 && scrollLeft === 0)
) {
return true;
}
}
return true;
}
function applyTouchMove(differenceX, differenceY) {
element.scrollTop -= differenceY;
element.scrollLeft -= differenceX;
updateGeometry(i);
}
let startOffset = {};
let startTime = 0;
const speed = {};
let easingLoop = null;
function getTouch(e) {
if (e.targetTouches) {
return e.targetTouches[0];
} else {
// Maybe IE pointer
return e;
}
}
function shouldHandle(e) {
if (e.pointerType && e.pointerType === 'pen' && e.buttons === 0) {
return false;
}
if (e.targetTouches && e.targetTouches.length === 1) {
return true;
}
if (
e.pointerType &&
e.pointerType !== 'mouse' &&
e.pointerType !== e.MSPOINTER_TYPE_MOUSE
) {
return true;
}
return false;
}
function touchStart(e) {
if (!shouldHandle(e)) {
return;
}
const touch = getTouch(e);
startOffset.pageX = touch.pageX;
startOffset.pageY = touch.pageY;
startTime = new Date().getTime();
if (easingLoop !== null) {
clearInterval(easingLoop);
}
}
function shouldBeConsumedByChild(target, deltaX, deltaY) {
if (!element.contains(target)) {
return false;
}
let cursor = target;
while (cursor && cursor !== element) {
if (cursor.classList.contains(cls.element.consuming)) {
return true;
}
const style = CSS.get(cursor);
// if deltaY && vertical scrollable
if (deltaY && style.overflowY.match(/(scroll|auto)/)) {
const maxScrollTop = cursor.scrollHeight - cursor.clientHeight;
if (maxScrollTop > 0) {
if (
(cursor.scrollTop > 0 && deltaY < 0) ||
(cursor.scrollTop < maxScrollTop && deltaY > 0)
) {
return true;
}
}
}
// if deltaX && horizontal scrollable
if (deltaX && style.overflowX.match(/(scroll|auto)/)) {
const maxScrollLeft = cursor.scrollWidth - cursor.clientWidth;
if (maxScrollLeft > 0) {
if (
(cursor.scrollLeft > 0 && deltaX < 0) ||
(cursor.scrollLeft < maxScrollLeft && deltaX > 0)
) {
return true;
}
}
}
cursor = cursor.parentNode;
}
return false;
}
function touchMove(e) {
if (shouldHandle(e)) {
const touch = getTouch(e);
const currentOffset = { pageX: touch.pageX, pageY: touch.pageY };
const differenceX = currentOffset.pageX - startOffset.pageX;
const differenceY = currentOffset.pageY - startOffset.pageY;
if (shouldBeConsumedByChild(e.target, differenceX, differenceY)) {
return;
}
applyTouchMove(differenceX, differenceY);
startOffset = currentOffset;
const currentTime = new Date().getTime();
const timeGap = currentTime - startTime;
if (timeGap > 0) {
speed.x = differenceX / timeGap;
speed.y = differenceY / timeGap;
startTime = currentTime;
}
if (shouldPrevent(differenceX, differenceY)) {
e.preventDefault();
}
}
}
function touchEnd() {
if (i.settings.swipeEasing) {
clearInterval(easingLoop);
easingLoop = setInterval(function () {
if (i.isInitialized) {
clearInterval(easingLoop);
return;
}
if (!speed.x && !speed.y) {
clearInterval(easingLoop);
return;
}
if (Math.abs(speed.x) < 0.01 && Math.abs(speed.y) < 0.01) {
clearInterval(easingLoop);
return;
}
if (!i.element) {
clearInterval(easingLoop);
return;
}
applyTouchMove(speed.x * 30, speed.y * 30);
speed.x *= 0.8;
speed.y *= 0.8;
}, 10);
}
}
if (env.supportsTouch) {
i.event.bind(element, 'touchstart', touchStart);
i.event.bind(element, 'touchmove', touchMove);
i.event.bind(element, 'touchend', touchEnd);
} else if (env.supportsIePointer) {
if (window.PointerEvent) {
i.event.bind(element, 'pointerdown', touchStart);
i.event.bind(element, 'pointermove', touchMove);
i.event.bind(element, 'pointerup', touchEnd);
} else if (window.MSPointerEvent) {
i.event.bind(element, 'MSPointerDown', touchStart);
i.event.bind(element, 'MSPointerMove', touchMove);
i.event.bind(element, 'MSPointerUp', touchEnd);
}
}
}

View File

@@ -0,0 +1,241 @@
import updateGeometry from './update-geometry';
import processScrollDiff from './process-scroll-diff';
import { toInt, outerWidth } from './lib/util';
import EventManager from './lib/event-manager';
import * as DOM from './lib/dom';
import * as CSS from './lib/css';
import cls from './lib/class-names';
import touch from './handlers/touch';
import wheel from './handlers/mouse-wheel';
import keyboard from './handlers/keyboard';
import dragThumb from './handlers/drag-thumb';
import clickRail from './handlers/click-rail';
const defaultSettings = () => ({
handlers: ['click-rail', 'drag-thumb', 'keyboard', 'wheel', 'touch'],
maxScrollbarLength: null,
minScrollbarLength: null,
scrollingThreshold: 1000,
scrollXMarginOffset: 0,
scrollYMarginOffset: 0,
suppressScrollX: false,
suppressScrollY: false,
swipeEasing: true,
useBothWheelAxes: false,
wheelPropagation: true,
wheelSpeed: 1,
});
const handlers = {
'click-rail': clickRail,
'drag-thumb': dragThumb,
keyboard,
wheel,
touch,
};
export default class PerfectScrollbar {
constructor(element, userSettings = {}) {
if (typeof element === 'string') {
element = document.querySelector(element);
}
if (!element || !element.nodeName) {
throw new Error('no element is specified to initialize PerfectScrollbar');
}
this.element = element;
element.classList.add(cls.main);
this.settings = defaultSettings();
for (const key in userSettings) {
this.settings[key] = userSettings[key];
}
this.containerWidth = null;
this.containerHeight = null;
this.contentWidth = null;
this.contentHeight = null;
const focus = () => element.classList.add(cls.state.focus);
const blur = () => element.classList.remove(cls.state.focus);
this.isRtl = CSS.get(element).direction === 'rtl';
if (this.isRtl === true) {
element.classList.add(cls.rtl);
}
this.isNegativeScroll = (() => {
const originalScrollLeft = element.scrollLeft;
let result = null;
element.scrollLeft = -1;
result = element.scrollLeft < 0;
element.scrollLeft = originalScrollLeft;
return result;
})();
this.negativeScrollAdjustment = this.isNegativeScroll
? element.scrollWidth - element.clientWidth
: 0;
this.event = new EventManager();
this.ownerDocument = element.ownerDocument || document;
this.scrollbarXRail = DOM.div(cls.element.rail('x'));
element.appendChild(this.scrollbarXRail);
this.scrollbarX = DOM.div(cls.element.thumb('x'));
this.scrollbarXRail.appendChild(this.scrollbarX);
this.scrollbarX.setAttribute('tabindex', 0);
this.event.bind(this.scrollbarX, 'focus', focus);
this.event.bind(this.scrollbarX, 'blur', blur);
this.scrollbarXActive = null;
this.scrollbarXWidth = null;
this.scrollbarXLeft = null;
const railXStyle = CSS.get(this.scrollbarXRail);
this.scrollbarXBottom = parseInt(railXStyle.bottom, 10);
if (isNaN(this.scrollbarXBottom)) {
this.isScrollbarXUsingBottom = false;
this.scrollbarXTop = toInt(railXStyle.top);
} else {
this.isScrollbarXUsingBottom = true;
}
this.railBorderXWidth =
toInt(railXStyle.borderLeftWidth) + toInt(railXStyle.borderRightWidth);
// Set rail to display:block to calculate margins
CSS.set(this.scrollbarXRail, { display: 'block' });
this.railXMarginWidth =
toInt(railXStyle.marginLeft) + toInt(railXStyle.marginRight);
CSS.set(this.scrollbarXRail, { display: '' });
this.railXWidth = null;
this.railXRatio = null;
this.scrollbarYRail = DOM.div(cls.element.rail('y'));
element.appendChild(this.scrollbarYRail);
this.scrollbarY = DOM.div(cls.element.thumb('y'));
this.scrollbarYRail.appendChild(this.scrollbarY);
this.scrollbarY.setAttribute('tabindex', 0);
this.event.bind(this.scrollbarY, 'focus', focus);
this.event.bind(this.scrollbarY, 'blur', blur);
this.scrollbarYActive = null;
this.scrollbarYHeight = null;
this.scrollbarYTop = null;
const railYStyle = CSS.get(this.scrollbarYRail);
this.scrollbarYRight = parseInt(railYStyle.right, 10);
if (isNaN(this.scrollbarYRight)) {
this.isScrollbarYUsingRight = false;
this.scrollbarYLeft = toInt(railYStyle.left);
} else {
this.isScrollbarYUsingRight = true;
}
this.scrollbarYOuterWidth = this.isRtl ? outerWidth(this.scrollbarY) : null;
this.railBorderYWidth =
toInt(railYStyle.borderTopWidth) + toInt(railYStyle.borderBottomWidth);
CSS.set(this.scrollbarYRail, { display: 'block' });
this.railYMarginHeight =
toInt(railYStyle.marginTop) + toInt(railYStyle.marginBottom);
CSS.set(this.scrollbarYRail, { display: '' });
this.railYHeight = null;
this.railYRatio = null;
this.reach = {
x:
element.scrollLeft <= 0
? 'start'
: element.scrollLeft >= this.contentWidth - this.containerWidth
? 'end'
: null,
y:
element.scrollTop <= 0
? 'start'
: element.scrollTop >= this.contentHeight - this.containerHeight
? 'end'
: null,
};
this.isAlive = true;
this.settings.handlers.forEach(handlerName => handlers[handlerName](this));
this.lastScrollTop = Math.floor(element.scrollTop); // for onScroll only
this.lastScrollLeft = element.scrollLeft; // for onScroll only
this.event.bind(this.element, 'scroll', e => this.onScroll(e));
updateGeometry(this);
}
update() {
if (!this.isAlive) {
return;
}
// Recalcuate negative scrollLeft adjustment
this.negativeScrollAdjustment = this.isNegativeScroll
? this.element.scrollWidth - this.element.clientWidth
: 0;
// Recalculate rail margins
CSS.set(this.scrollbarXRail, { display: 'block' });
CSS.set(this.scrollbarYRail, { display: 'block' });
this.railXMarginWidth =
toInt(CSS.get(this.scrollbarXRail).marginLeft) +
toInt(CSS.get(this.scrollbarXRail).marginRight);
this.railYMarginHeight =
toInt(CSS.get(this.scrollbarYRail).marginTop) +
toInt(CSS.get(this.scrollbarYRail).marginBottom);
// Hide scrollbars not to affect scrollWidth and scrollHeight
CSS.set(this.scrollbarXRail, { display: 'none' });
CSS.set(this.scrollbarYRail, { display: 'none' });
updateGeometry(this);
processScrollDiff(this, 'top', 0, false, true);
processScrollDiff(this, 'left', 0, false, true);
CSS.set(this.scrollbarXRail, { display: '' });
CSS.set(this.scrollbarYRail, { display: '' });
}
onScroll(e) {
if (!this.isAlive) {
return;
}
updateGeometry(this);
processScrollDiff(this, 'top', this.element.scrollTop - this.lastScrollTop);
processScrollDiff(
this,
'left',
this.element.scrollLeft - this.lastScrollLeft,
);
this.lastScrollTop = Math.floor(this.element.scrollTop);
this.lastScrollLeft = this.element.scrollLeft;
}
destroy() {
if (!this.isAlive) {
return;
}
this.event.unbindAll();
DOM.remove(this.scrollbarX);
DOM.remove(this.scrollbarY);
DOM.remove(this.scrollbarXRail);
DOM.remove(this.scrollbarYRail);
this.removePsClasses();
// unset elements
this.element = null;
this.scrollbarX = null;
this.scrollbarY = null;
this.scrollbarXRail = null;
this.scrollbarYRail = null;
this.isAlive = false;
}
removePsClasses() {
this.element.className = this.element.className
.split(' ')
.filter(name => !name.match(/^ps([-_].+|)$/))
.join(' ');
}
}

View File

@@ -0,0 +1,45 @@
const cls = {
main: 'ide-ps',
rtl: 'ide-ps__rtl',
element: {
thumb: x => `ide-ps__thumb-${x}`,
rail: x => `ide-ps__rail-${x}`,
consuming: 'ide-ps__child--consume',
},
state: {
focus: 'ide-ps--focus',
clicking: 'ide-ps--clicking',
active: x => `ide-ps--active-${x}`,
scrolling: x => `ide-ps--scrolling-${x}`,
},
};
export default cls;
/*
* Helper methods
*/
const scrollingClassTimeout = { x: null, y: null };
export function addScrollingClass(i, x) {
const classList = i.element.classList;
const className = cls.state.scrolling(x);
if (classList.contains(className)) {
clearTimeout(scrollingClassTimeout[x]);
} else {
classList.add(className);
}
}
export function removeScrollingClass(i, x) {
scrollingClassTimeout[x] = setTimeout(
() => i.isAlive && i.element.classList.remove(cls.state.scrolling(x)),
i.settings.scrollingThreshold
);
}
export function setScrollingClassInstantly(i, x) {
addScrollingClass(i, x);
removeScrollingClass(i, x);
}

View File

@@ -0,0 +1,14 @@
export function get(element) {
return getComputedStyle(element);
}
export function set(element, obj) {
for (const key in obj) {
let val = obj[key];
if (typeof val === 'number') {
val = `${val}px`;
}
element.style[key] = val;
}
return element;
}

View File

@@ -0,0 +1,36 @@
export function div(className) {
const div = document.createElement('div');
div.className = className;
return div;
}
const elMatches =
typeof Element !== 'undefined' &&
(Element.prototype.matches ||
Element.prototype.webkitMatchesSelector ||
Element.prototype.mozMatchesSelector ||
Element.prototype.msMatchesSelector);
export function matches(element, query) {
if (!elMatches) {
throw new Error('No element matching method supported');
}
return elMatches.call(element, query);
}
export function remove(element) {
if (element.remove) {
element.remove();
} else {
if (element.parentNode) {
element.parentNode.removeChild(element);
}
}
}
export function queryChildren(element, selector) {
return Array.prototype.filter.call(element.children, child =>
matches(child, selector)
);
}

View File

@@ -0,0 +1,79 @@
class EventElement {
constructor(element) {
this.element = element;
this.handlers = {};
}
bind(eventName, handler) {
if (typeof this.handlers[eventName] === 'undefined') {
this.handlers[eventName] = [];
}
this.handlers[eventName].push(handler);
this.element.addEventListener(eventName, handler, false);
}
unbind(eventName, target) {
this.handlers[eventName] = this.handlers[eventName].filter(handler => {
if (target && handler !== target) {
return true;
}
this.element.removeEventListener(eventName, handler, false);
return false;
});
}
unbindAll() {
for (const name in this.handlers) {
this.unbind(name);
}
}
get isEmpty() {
return Object.keys(this.handlers).every(
key => this.handlers[key].length === 0
);
}
}
export default class EventManager {
constructor() {
this.eventElements = [];
}
eventElement(element) {
let ee = this.eventElements.filter(ee => ee.element === element)[0];
if (!ee) {
ee = new EventElement(element);
this.eventElements.push(ee);
}
return ee;
}
bind(element, eventName, handler) {
this.eventElement(element).bind(eventName, handler);
}
unbind(element, eventName, handler) {
const ee = this.eventElement(element);
ee.unbind(eventName, handler);
if (ee.isEmpty) {
// remove
this.eventElements.splice(this.eventElements.indexOf(ee), 1);
}
}
unbindAll() {
this.eventElements.forEach(e => e.unbindAll());
this.eventElements = [];
}
once(element, eventName, handler) {
const ee = this.eventElement(element);
const onceHandler = evt => {
ee.unbind(eventName, onceHandler);
handler(evt);
};
ee.bind(eventName, onceHandler);
}
}

View File

@@ -0,0 +1,43 @@
import * as CSS from './css';
import * as DOM from './dom';
export function toInt(x) {
return parseInt(x, 10) || 0;
}
export function isEditable(el) {
return (
DOM.matches(el, 'input,[contenteditable]') ||
DOM.matches(el, 'select,[contenteditable]') ||
DOM.matches(el, 'textarea,[contenteditable]') ||
DOM.matches(el, 'button,[contenteditable]')
);
}
export function outerWidth(element) {
const styles = CSS.get(element);
return (
toInt(styles.width) +
toInt(styles.paddingLeft) +
toInt(styles.paddingRight) +
toInt(styles.borderLeftWidth) +
toInt(styles.borderRightWidth)
);
}
export const env = {
isWebKit:
typeof document !== 'undefined' &&
'WebkitAppearance' in document.documentElement.style,
supportsTouch:
typeof window !== 'undefined' &&
('ontouchstart' in window ||
('maxTouchPoints' in window.navigator &&
window.navigator.maxTouchPoints > 0) ||
(window.DocumentTouch && document instanceof window.DocumentTouch)),
supportsIePointer:
typeof navigator !== 'undefined' && navigator.msMaxTouchPoints,
isChrome:
typeof navigator !== 'undefined' &&
/Chrome/i.test(navigator && navigator.userAgent),
};

View File

@@ -0,0 +1,85 @@
import { setScrollingClassInstantly } from './lib/class-names';
function createEvent(name) {
if (typeof window.CustomEvent === 'function') {
return new CustomEvent(name);
} else {
const evt = document.createEvent('CustomEvent');
evt.initCustomEvent(name, false, false, undefined);
return evt;
}
}
export default function (
i,
axis,
diff,
useScrollingClass = true,
forceFireReachEvent = false,
) {
let fields;
if (axis === 'top') {
fields = [
'contentHeight',
'containerHeight',
'scrollTop',
'y',
'up',
'down',
];
} else if (axis === 'left') {
fields = [
'contentWidth',
'containerWidth',
'scrollLeft',
'x',
'left',
'right',
];
} else {
throw new Error('A proper axis should be provided');
}
processScrollDiff(i, diff, fields, useScrollingClass, forceFireReachEvent);
}
function processScrollDiff(
i,
diff,
[contentHeight, containerHeight, scrollTop, y, up, down],
useScrollingClass = true,
forceFireReachEvent = false,
) {
const { element } = i;
// reset reach
i.reach[y] = null;
// 1 for subpixel rounding
if (element[scrollTop] < 1) {
i.reach[y] = 'start';
}
// 1 for subpixel rounding
if (element[scrollTop] > i[contentHeight] - i[containerHeight] - 1) {
i.reach[y] = 'end';
}
if (diff) {
element.dispatchEvent(createEvent(`ide-ps-scroll-${y}`));
if (diff < 0) {
element.dispatchEvent(createEvent(`ide-ps-scroll-${up}`));
} else if (diff > 0) {
element.dispatchEvent(createEvent(`ide-ps-scroll-${down}`));
}
if (useScrollingClass) {
setScrollingClassInstantly(i, y);
}
}
if (i.reach[y] && (diff || forceFireReachEvent)) {
element.dispatchEvent(createEvent(`ide-ps-${y}-reach-${i.reach[y]}`));
}
}

View File

@@ -0,0 +1,78 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare namespace PerfectScrollbar {
export interface Options {
handlers?: string[];
maxScrollbarLength?: number;
minScrollbarLength?: number;
scrollingThreshold?: number;
scrollXMarginOffset?: number;
scrollYMarginOffset?: number;
suppressScrollX?: boolean;
suppressScrollY?: boolean;
swipeEasing?: boolean;
useBothWheelAxes?: boolean;
wheelPropagation?: boolean;
wheelSpeed?: number;
}
}
declare class PerfectScrollbar {
constructor(element: string | Element, options?: PerfectScrollbar.Options);
update(): void;
destroy(): void;
containerHeight: number;
containerWidth: number;
contentHeight: number;
contentWidth: number;
element: HTMLElement;
isAlive: boolean;
isNegativeScroll: boolean;
isRtl: boolean;
isScrollbarXUsingBottom: boolean;
isScrollbarYUsingBottom: boolean;
lastScrollLeft: boolean;
lastScrollTop: boolean;
negativeScrollAdjustment: number;
railBorderXWidth: number;
railBorderYWidth: number;
railXMarginWidth: number;
railXRatio: number;
railXWidth: number;
railYHeight: number;
railYMarginHeight: number;
railYRatio: number;
scrollbarX: HTMLElement;
scrollbarXActive: boolean;
scrollbarXBottom: number;
scrollbarXLeft: number;
scrollbarXRail: HTMLElement;
scrollbarXWidth: number;
scrollbarY: HTMLElement;
scrollbarYActive: boolean;
scrollbarYHeight: number;
scrollbarYOuterWidth?: number;
scrollbarYRail: HTMLElement;
scrollbarYRight: number;
scrollbarYTop: number;
settings: PerfectScrollbar.Options;
reach: { x: 'start' | 'end' | null, y: 'start' | 'end' | null };
}
export default PerfectScrollbar;

View File

@@ -0,0 +1,170 @@
import { toInt } from './lib/util';
import * as DOM from './lib/dom';
import * as CSS from './lib/css';
import cls from './lib/class-names';
export default function (i, direction) {
const { element } = i;
const roundedScrollTop = Math.floor(element.scrollTop);
const rect = element.getBoundingClientRect();
i.containerWidth = Math.round(rect.width);
i.containerHeight = Math.round(rect.height);
i.contentWidth = element.scrollWidth;
i.contentHeight = element.scrollHeight;
if (
!element.contains(i.scrollbarXRail) &&
(!direction || direction === 'x')
) {
// clean up and append
DOM.queryChildren(element, cls.element.rail('x')).forEach(el =>
DOM.remove(el),
);
element.appendChild(i.scrollbarXRail);
}
if (
!element.contains(i.scrollbarYRail) &&
(!direction || direction === 'y')
) {
// clean up and append
DOM.queryChildren(element, cls.element.rail('y')).forEach(el =>
DOM.remove(el),
);
element.appendChild(i.scrollbarYRail);
}
if (
!i.settings.suppressScrollX &&
i.containerWidth + i.settings.scrollXMarginOffset < i.contentWidth
) {
i.scrollbarXActive = true;
i.railXWidth = i.containerWidth - i.railXMarginWidth;
i.railXRatio = i.containerWidth / i.railXWidth;
i.scrollbarXWidth = getThumbSize(
i,
toInt((i.railXWidth * i.containerWidth) / i.contentWidth),
);
i.scrollbarXLeft = toInt(
((i.negativeScrollAdjustment + element.scrollLeft) *
(i.railXWidth - i.scrollbarXWidth)) /
(i.contentWidth - i.containerWidth),
);
} else {
i.scrollbarXActive = false;
}
if (
!i.settings.suppressScrollY &&
i.containerHeight + i.settings.scrollYMarginOffset < i.contentHeight
) {
i.scrollbarYActive = true;
i.railYHeight = i.containerHeight - i.railYMarginHeight;
i.railYRatio = i.containerHeight / i.railYHeight;
i.scrollbarYHeight = getThumbSize(
i,
toInt((i.railYHeight * i.containerHeight) / i.contentHeight),
);
i.scrollbarYTop = toInt(
(roundedScrollTop * (i.railYHeight - i.scrollbarYHeight)) /
(i.contentHeight - i.containerHeight),
);
} else {
i.scrollbarYActive = false;
}
if (i.scrollbarXLeft >= i.railXWidth - i.scrollbarXWidth) {
i.scrollbarXLeft = i.railXWidth - i.scrollbarXWidth;
}
if (i.scrollbarYTop >= i.railYHeight - i.scrollbarYHeight) {
i.scrollbarYTop = i.railYHeight - i.scrollbarYHeight;
}
updateCss(element, i);
if (i.scrollbarXActive) {
element.classList.add(cls.state.active('x'));
} else {
element.classList.remove(cls.state.active('x'));
i.scrollbarXWidth = 0;
i.scrollbarXLeft = 0;
element.scrollLeft = i.isRtl === true ? i.contentWidth : 0;
}
if (i.scrollbarYActive) {
element.classList.add(cls.state.active('y'));
} else {
element.classList.remove(cls.state.active('y'));
i.scrollbarYHeight = 0;
i.scrollbarYTop = 0;
element.scrollTop = 0;
}
}
function getThumbSize(i, thumbSize) {
if (i.settings.minScrollbarLength) {
thumbSize = Math.max(thumbSize, i.settings.minScrollbarLength);
}
if (i.settings.maxScrollbarLength) {
thumbSize = Math.min(thumbSize, i.settings.maxScrollbarLength);
}
return thumbSize;
}
function updateCss(element, i) {
const xRailOffset = { width: i.railXWidth };
const roundedScrollTop = Math.floor(element.scrollTop);
if (i.isRtl) {
xRailOffset.left =
i.negativeScrollAdjustment +
element.scrollLeft +
i.containerWidth -
i.contentWidth;
} else {
xRailOffset.left = element.scrollLeft;
}
if (i.isScrollbarXUsingBottom) {
xRailOffset.bottom = i.scrollbarXBottom - roundedScrollTop;
} else {
xRailOffset.top = i.scrollbarXTop + roundedScrollTop;
}
CSS.set(i.scrollbarXRail, xRailOffset);
const yRailOffset = { top: roundedScrollTop, height: i.railYHeight };
if (i.isScrollbarYUsingRight) {
if (i.isRtl) {
yRailOffset.right =
i.contentWidth -
(i.negativeScrollAdjustment + element.scrollLeft) -
i.scrollbarYRight -
i.scrollbarYOuterWidth -
9;
} else {
yRailOffset.right = i.scrollbarYRight - element.scrollLeft;
}
} else {
if (i.isRtl) {
yRailOffset.left =
i.negativeScrollAdjustment +
element.scrollLeft +
i.containerWidth * 2 -
i.contentWidth -
i.scrollbarYLeft -
i.scrollbarYOuterWidth;
} else {
yRailOffset.left = i.scrollbarYLeft + element.scrollLeft;
}
}
CSS.set(i.scrollbarYRail, yRailOffset);
CSS.set(i.scrollbarX, {
left: i.scrollbarXLeft,
width: i.scrollbarXWidth - i.railBorderXWidth,
});
CSS.set(i.scrollbarY, {
top: i.scrollbarYTop,
height: i.scrollbarYHeight - i.railBorderYWidth,
});
}

View File

@@ -0,0 +1,89 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useMemo } from 'react';
import {
LabelService,
useIDEService,
useStyling,
} from '@coze-project-ide/core';
import { type StatusBarItem } from '../../types/view';
interface StatusBarProps {
items: StatusBarItem[];
}
const StatusBarItem: React.FC<{ item: StatusBarItem }> = ({ item }) => {
const labelService = useIDEService<LabelService>(LabelService);
const label = useMemo(() => labelService.renderer(item.uri), [item.uri]);
if (!label) {
return null;
}
return <div className="flowide-status-bar-item">{label}</div>;
};
const StatusBar: React.FC<StatusBarProps> = ({ items }) => {
useStyling(
'flowide-status-bar-widget',
(_, { getColor }) => `
.flowide-status-bar-widget-container {
display: flex;
height: 100%;
justify-content: space-between;
padding: 0 8px;
}
.flowide-status-bar-side {
display: flex;
}
.flowide-status-bar-item {
height: 100%;
cursor: pointer;
padding: 0 4px;
margin: 0 2px;
font-size: 12px;
color: ${getColor('flowide.color.base.text.0')};
display: flex;
align-items: center;
}
.flowide-status-bar-item:hover {
background: ${getColor('flowide.color.base.fill.0')}
}
`,
);
return (
<div className="flowide-status-bar-widget-container">
<div className="flowide-status-bar-side">
{items
.filter(item => item.position === 'left')
.map(item => (
<StatusBarItem item={item} key={item.uri.toString()} />
))}
</div>
<div className="flowide-status-bar-side">
{items
.filter(item => item.position === 'right')
.map(item => (
<StatusBarItem item={item} key={item.uri.toString()} />
))}
</div>
</div>
);
};
export { StatusBar };

View File

@@ -0,0 +1,19 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export const MAIN_PANEL_ID = 'flowide-main-panel';
export const BOTTOM_PANEL_ID = 'flowide-bottom-panel';

View File

@@ -0,0 +1,32 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export const CUSTOM_TAB_BAR_CONTAINER = 'custom-tabBar-container';
// 前边的 action
export const PRE_TAB_BAR_ACTION_CONTAINER = 'pre-flow-tabBar-action-container';
// 后边的 action
export const TAB_BAR_ACTION_CONTAINER = 'flow-tabBar-action-container';
// tab bar 滚动区域
export const TAB_BAR_SCROLL_CONTAINER = 'flow-tabBar-scroll-container';
// toolbar
export const TAB_BAR_TOOLBAR = 'flow-toolbar-container';
export const TAB_BAR_TOOLBAR_ITEM = 'flow-toolbar-item';
export const DISABLE_HANDLE_EVENT = 'disable-handle-event';
/** DebugBar 可拖拽区域 classname */
export const DEBUG_BAR_DRAGGABLE = 'flow-debug-bar-draggable';

View File

@@ -0,0 +1,17 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export const SINGLE_MODE = 'single-document';

View File

@@ -0,0 +1,19 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { URI } from '@coze-project-ide/core';
export const HOVER_TOOLTIP_LABEL = new URI('flowide:///hover/tooltip-label');

View File

@@ -0,0 +1,21 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { VIEW_CONTAINER_CLASS_NAME, PANEL_CLASS_NAME_MAP } from './view';
export * from './class-names';
export * from './hover';
export * from './area-id';
export { SINGLE_MODE } from './dock-panel';

View File

@@ -0,0 +1,19 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { type ViewPluginOptions } from '../types';
export const ViewOptions = Symbol('ViewOptions');
export type ViewOptions = ViewPluginOptions;

View File

@@ -0,0 +1,54 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { URI } from '@coze-project-ide/core';
import { LayoutPanelType } from '../types';
import { BOTTOM_PANEL_ID, MAIN_PANEL_ID } from './area-id';
export const VIEW_CONTAINER_CLASS_NAME = 'flowide-container';
export const PANEL_CLASS_NAME_MAP: Record<LayoutPanelType, URI> = {
[LayoutPanelType.TOP_BAR]: new URI('flowide:///panel/flowide-top-bar'),
[LayoutPanelType.ACTIVITY_BAR]: new URI(
'flowide:///panel/flowide-activity-bar',
),
[LayoutPanelType.PRIMARY_SIDEBAR]: new URI(
'flowide:///panel/flowide-primary-sidebar',
),
[LayoutPanelType.MAIN_PANEL]: new URI(`flowide:///panel/${MAIN_PANEL_ID}`),
[LayoutPanelType.SECONDARY_SIDEBAR]: new URI(
'flowide:///panel/flowide-secondary-sidebar',
),
[LayoutPanelType.BOTTOM_PANEL]: new URI(
`flowide:///panel/${BOTTOM_PANEL_ID}`,
),
[LayoutPanelType.STATUS_BAR]: new URI('flowide:///panel/flowide-status-bar'),
[LayoutPanelType.RIGHT_BAR]: new URI('flowide:///panel/flowide-right-bar'),
};
export const ALL_PANEL_TYPES = [
LayoutPanelType.TOP_BAR,
LayoutPanelType.ACTIVITY_BAR,
LayoutPanelType.PRIMARY_SIDEBAR,
LayoutPanelType.MAIN_PANEL,
LayoutPanelType.SECONDARY_SIDEBAR,
LayoutPanelType.BOTTOM_PANEL,
LayoutPanelType.STATUS_BAR,
LayoutPanelType.RIGHT_BAR,
];
export const SPLIT_PANEL_CLASSNAME = 'flow-split-widget-panel';

View File

@@ -0,0 +1,47 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { type interfaces } from 'inversify';
import { type URI } from '@coze-project-ide/core';
import { WidgetFactory } from '../widget/widget-factory';
import { STATUS_BAR_CONTENT } from '../widget/react-widgets/status-bar-widget';
import { ACTIVITY_BAR_CONTENT } from '../widget/react-widgets/activity-bar-widget';
import { ActivityBarWidget, StatusBarWidget } from '../widget/react-widgets';
import { LayoutPanelType } from '../types';
export const bindActivityBarView = (bind: interfaces.Bind): void => {
bind(WidgetFactory).toDynamicValue(({ container }) => ({
area: LayoutPanelType.ACTIVITY_BAR,
canHandle: (uri: URI) => uri.isEqualOrParent(ACTIVITY_BAR_CONTENT),
createWidget: () => {
const childContainer = container.createChild();
childContainer.bind(ActivityBarWidget).toSelf().inSingletonScope();
return childContainer.get(ActivityBarWidget);
},
}));
bind(WidgetFactory).toDynamicValue(({ container }) => ({
area: LayoutPanelType.STATUS_BAR,
canHandle: (uri: URI) => uri.isEqualOrParent(STATUS_BAR_CONTENT),
createWidget: () => {
const childContainer = container.createChild();
childContainer.bind(StatusBarWidget).toSelf().inSingletonScope();
return childContainer.get(StatusBarWidget);
},
}));
};

View File

@@ -0,0 +1,35 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ContainerModule } from 'inversify';
import { bindContributions } from '@flowgram-adapter/common';
import { LifecycleContribution } from '@coze-project-ide/core';
import { MenuService, MenuRegistry } from './menu-registry';
import { Menu, MenuFactory } from './menu';
import { ContextMenu } from './context-menu';
export const ContextMenuContainerModule = new ContainerModule(bind => {
bind(MenuService).toService(MenuRegistry);
bindContributions(bind, MenuRegistry, [LifecycleContribution]);
bind(MenuFactory).toFactory(context => () => {
const container = context.container.createChild();
container.bind(Menu).toSelf().inSingletonScope();
return container.get(Menu);
});
bind(ContextMenu).toSelf().inSingletonScope();
});

View File

@@ -0,0 +1,271 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type React from 'react';
import { inject, injectable, postConstruct } from 'inversify';
import { Disposable } from '@flowgram-adapter/common';
import { Selector } from '../../lumino/domutils';
import { ArrayExt } from '../../lumino/algorithm';
import { Menu, MenuFactory } from './menu';
export type CanHandle = string | ((command?: string) => boolean);
/**
* 全局 contextmenu 监听
*/
@injectable()
export class ContextMenu {
@inject(MenuFactory) menuFactory: MenuFactory;
// 全局主菜单
menu: Menu;
@postConstruct()
init() {
this.menu = this.menuFactory();
}
/**
* 删除项
*/
deleteItem(canHandle: CanHandle) {
if (typeof canHandle === 'string') {
// 精确删除
const item = this._items.find(i => i.command === canHandle);
ArrayExt.removeFirstOf(this._items, item);
} else {
// 模糊删除
this._items.forEach(i => {
if (canHandle(i.command)) {
ArrayExt.removeFirstOf(this._items, i);
}
});
}
}
/**
* 添加项
*/
addItem(options: ContextMenu.IItemOptions): Disposable {
const item = Private.createItem(options, this._idTick++);
this._items.push(item);
return Disposable.create(() => {
ArrayExt.removeFirstOf(this._items, item);
});
}
/**
* 手动关闭 menu
*/
close() {
this.menu.close();
}
/**
* 打开事件
*/
open(event: React.MouseEvent, args?: any): boolean {
Menu.saveWindowData();
this.menu.clearItems();
if (this._items.length === 0) {
return false;
}
const items = Private.matchItems(
this._items,
event,
this._groupByTarget,
this._sortBySelector,
);
if (!items || items.length === 0) {
return false;
}
for (const item of items) {
if (args) {
item.args = args;
}
if (item.filter && !item.filter(args)) {
continue;
}
this.menu.addItem(item);
}
this.menu.open(event.clientX, event.clientY);
return true;
}
private _groupByTarget = true;
private _idTick = 0;
private _items: Private.IItem[] = [];
private _sortBySelector = true;
}
export namespace ContextMenu {
export interface IOptions {
renderer?: Menu.IRenderer;
sortBySelector?: boolean;
groupByTarget?: boolean;
}
/**
* An options object for creating a context menu item.
*/
export interface IItemOptions extends Menu.IItemOptions {
/**
* The CSS selector for the context menu item.
*
* 只有当当前元素冒泡途径 selector 元素,才会触发这个 contextmenu 事件。
* 底层通过 querySelector 获取,需要加上 commas
*/
selector: string;
/**
* The default rank is `Infinity`.
*/
rank?: number;
}
}
namespace Private {
export interface IItem extends Menu.IItemOptions {
selector: string;
rank: number;
id: number;
}
/**
* Create a normalized context menu item from an options object.
*/
export function createItem(
options: ContextMenu.IItemOptions,
id: number,
): IItem {
const selector = validateSelector(options.selector);
const rank = options.rank !== undefined ? options.rank : Infinity;
return { ...options, selector, rank, id };
}
export function matchItems(
items: IItem[],
event: React.MouseEvent,
groupByTarget: boolean,
sortBySelector: boolean,
): IItem[] | null {
let target = event.target as Element | null;
if (!target) {
return null;
}
const currentTarget = event.currentTarget as Element | null;
if (!currentTarget) {
return null;
}
const result: IItem[] = [];
const availableItems: Array<IItem | null> = items.slice();
while (target !== null) {
const matches: IItem[] = [];
for (let i = 0, n = availableItems.length; i < n; ++i) {
const item = availableItems[i];
if (!item) {
continue;
}
if (!Selector.matches(target, item.selector)) {
continue;
}
matches.push(item);
availableItems[i] = null;
}
if (matches.length !== 0) {
if (groupByTarget) {
matches.sort(sortBySelector ? itemCmp : itemCmpRank);
}
result.push(...matches);
}
if (target === currentTarget) {
break;
}
target = target.parentElement;
}
if (!groupByTarget) {
result.sort(sortBySelector ? itemCmp : itemCmpRank);
}
return result;
}
function validateSelector(selector: string): string {
if (selector.indexOf(',') !== -1) {
throw new Error(`Selector cannot contain commas: ${selector}`);
}
if (!Selector.isValid(selector)) {
throw new Error(`Invalid selector: ${selector}`);
}
return selector;
}
function itemCmpRank(a: IItem, b: IItem): number {
const r1 = a.rank;
const r2 = b.rank;
if (r1 !== r2) {
return r1 < r2 ? -1 : 1; // Infinity-safe
}
return a.id - b.id;
}
/**
* A sort comparison function for a context menu item by selectors and ranks.
*/
function itemCmp(a: IItem, b: IItem): number {
const s1 = Selector.calculateSpecificity(a.selector);
const s2 = Selector.calculateSpecificity(b.selector);
if (s1 !== s2) {
return s2 - s1;
}
return itemCmpRank(a, b);
}
}

View File

@@ -0,0 +1,23 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { definePluginCreator } from '@coze-project-ide/core';
import { ContextMenuContainerModule } from './context-menu-container-module';
export const createContextMenuPlugin = definePluginCreator<void>({
containerModules: [ContextMenuContainerModule],
});

View File

@@ -0,0 +1,18 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { createContextMenuPlugin } from './create-context-menu-plugin';
export { MenuService } from './menu-registry';

View File

@@ -0,0 +1,83 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type React from 'react';
import { inject, injectable } from 'inversify';
import { type LifecycleContribution } from '@coze-project-ide/core';
import { type Menu, MenuFactory } from './menu';
import { type CanHandle, ContextMenu } from './context-menu';
export const MenuService = Symbol('MenuService');
/**
* menu service 注册
*/
export interface MenuService {
addMenuItem: (options: ContextMenu.IItemOptions) => void;
createSubMenu: () => Menu;
addSubMenuItem: (submenu: Menu, options: Menu.IItemOptions) => void;
open: (event: React.MouseEvent, args?: any) => boolean;
clearMenuItems: (canHandles: CanHandle[]) => void;
close: () => void;
}
@injectable()
export class MenuRegistry implements MenuService, LifecycleContribution {
@inject(ContextMenu) contextMenu: ContextMenu;
@inject(MenuFactory) menuFactory: MenuFactory;
onInit() {}
clearMenuItems(canHandles: CanHandle[]) {
canHandles.forEach(handle => {
this.contextMenu.deleteItem(handle);
});
}
clearMenuItem(canHandle: string | ((command: string) => boolean)) {
if (typeof canHandle === 'string') {
}
}
addMenuItem(options: ContextMenu.IItemOptions): void {
this.contextMenu.addItem(options);
}
createSubMenu(): Menu {
const submenu = this.menuFactory();
return submenu;
}
addSubMenuItem(submenu: Menu, options: Menu.IItemOptions): void {
submenu.addItem(options);
}
open(event: React.MouseEvent, args?: any): boolean {
return this.contextMenu.open(event, args);
}
close() {
this.contextMenu.close();
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,19 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { bindActivityBarView } from './activity-bar-contribution';
export { ViewCommonContribution } from './view-common-contribution';
export { ViewContribution } from './view-contribution';

View File

@@ -0,0 +1,94 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export const getFlowMenuStyle = (getColor: (id: string) => string): string => `
.flow-Menu {
z-index: 10000;
position: absolute;
top: 0;
left: 0;
padding: 4px;
white-space: nowrap;
overflow-x: hidden;
overflow-y: auto;
outline: none;
font: 12px Helvetica,Arial,sans-serif;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
background: ${getColor('flowide.color.base.bg.0')};
color: ${getColor('flowide.color.base.text.0')};
border: 1px solid ${getColor('flowide.color.menu.border')};
box-shadow: 0 1px 6px ${getColor('flowide.color.menu.box.shadow')};
border-radius: 6px;
}
.flow-Menu-content {
margin: 0;
padding: 0;
display: flex;
outline: none;
flex-direction: column;
list-style-type: none;
}
.flow-Menu-item {
display: flex;
justify-content: space-between;
padding: 0 4px;
border-radius: 4px;
align-items: center;
outline: none;
cursor: pointer;
}
.flow-Menu-item.flow-mod-active {
background: ${getColor('flowide.color.base.fill.0')};
}
.flow-Menu-item.flow-mod-disabled {
opacity: 0.35;
}
.flow-Menu-item.flow-mod-hidden,
.flow-Menu-item.flow-mod-collapsed {
display: none !important;
}
.flow-Menu-itemIcon,
.flow-Menu-itemSubmenuIcon {
text-align: center;
}
.flow-Menu-itemLabel {
text-align: left;
padding: 4px 35px 4px 2px;
}
.flow-Menu-itemShortcut {
text-align: right;
}
.flow-Menu-itemIcon::before,
.flow-Menu-itemSubmenuIcon::before {
font-family: codicon;
}
.flow-Menu-item > .flow-Menu-itemSubmenuIcon::before {
content: '\\eab6';
line-height: 20px;
}
`;

View File

@@ -0,0 +1,93 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { LayoutPanelType } from '../../types';
import { SPLIT_PANEL_CLASSNAME } from '../../constants/view';
import { PANEL_CLASS_NAME_MAP } from '../../constants';
export const getPanelStyle = (getColor: (id: string) => string): string => `
#${PANEL_CLASS_NAME_MAP[LayoutPanelType.TOP_BAR].displayName} {
min-height: 40px;
background: ${getColor('flowide.color.base.bg.2')};
color: ${getColor('flowide.color.base.text.0')};
border-bottom: 1px solid ${getColor('flowide.color.base.border')};
}
#${PANEL_CLASS_NAME_MAP[LayoutPanelType.ACTIVITY_BAR].displayName} {
min-width: 36px;
color: ${getColor('flowide.color.base.text.0')};
background: ${getColor('flowide.color.base.bg.2')};
border-right: 1px solid ${getColor('flowide.color.base.border')};
}
#${PANEL_CLASS_NAME_MAP[LayoutPanelType.PRIMARY_SIDEBAR].displayName} {
min-width: 110px !important;
color: ${getColor('flowide.color.base.text.0')};
background: ${getColor('flowide.color.base.bg.1')};
border-right: 1px solid ${getColor('flowide.color.base.border')};
}
#${PANEL_CLASS_NAME_MAP[LayoutPanelType.MAIN_PANEL].displayName} {
color: ${getColor('flowide.color.base.text.0')};
background: ${getColor('flowide.color.base.bg.0')};
}
#${PANEL_CLASS_NAME_MAP[LayoutPanelType.SECONDARY_SIDEBAR].displayName} {
min-width: 110px;
}
#${PANEL_CLASS_NAME_MAP[LayoutPanelType.STATUS_BAR].displayName} {
min-height: 22px;
background: ${getColor('flowide.color.base.bg.2')};
border-top: 1px solid ${getColor('flowide.color.base.border')};
}
#${PANEL_CLASS_NAME_MAP[LayoutPanelType.BOTTOM_PANEL].displayName} {
background: ${getColor('flowide.color.base.bg.0')};
color: ${getColor('flowide.color.base.text.0')};
border-top: 1px solid ${getColor('flowide.color.base.border')};
}
.${SPLIT_PANEL_CLASSNAME} .lm-SplitPanel-child.expand {
min-height: 75px;
}
.${SPLIT_PANEL_CLASSNAME} .lm-SplitPanel-child.close {
min-height: 22px;
max-height: 22px;
}
.${SPLIT_PANEL_CLASSNAME}[data-orientation="vertical"] .lm-SplitPanel-handle {
background: ${getColor('flowide.color.base.border')};
min-height: 1px;
z-index: 3;
}
.${SPLIT_PANEL_CLASSNAME}[data-orientation="vertical"] .lm-SplitPanel-handle:hover {
background: ${getColor('flowide.color.base.primary.hover')};
min-height: 4px;
}
.${SPLIT_PANEL_CLASSNAME}[data-orientation="vertical"] .lm-SplitPanel-handle:active {
background: ${getColor('flowide.color.base.primary')};
min-height: 4px;
}
.${SPLIT_PANEL_CLASSNAME}[data-orientation="horizontal"] .lm-SplitPanel-handle {
background: ${getColor('flowide.color.base.border')};
min-width: 1px;
}
.${SPLIT_PANEL_CLASSNAME}[data-orientation="horizontal"] .lm-SplitPanel-handle:hover {
background: ${getColor('flowide.color.base.primary.hover')};
min-width: 4px;
}
.${SPLIT_PANEL_CLASSNAME}[data-orientation="horizontal"] .lm-SplitPanel-handle:active {
background: ${getColor('flowide.color.base.primary')};
min-width: 4px;
}
`;

View File

@@ -0,0 +1,417 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { inject, injectable } from 'inversify';
import {
type CommandContribution,
type CommandRegistry,
type ShortcutsContribution,
type StylingContribution,
type Collector,
type ColorTheme,
type ShortcutsRegistry,
CommandRegistryFactory,
Command,
NavigationService,
} from '@coze-project-ide/core';
import { WidgetOpenHandler } from '../widget/widget-open-handler';
import { type FlowDockPanel } from '../widget/dock-panel';
import { type CustomTitleType } from '../types';
import { ApplicationShell } from '../shell';
import { ViewService } from '../services/view-service';
import {
BOTTOM_PANEL_ID,
CUSTOM_TAB_BAR_CONTAINER,
MAIN_PANEL_ID,
TAB_BAR_ACTION_CONTAINER,
TAB_BAR_SCROLL_CONTAINER,
TAB_BAR_TOOLBAR,
TAB_BAR_TOOLBAR_ITEM,
} from '../constants';
import { getPanelStyle } from './styles/panel-style';
import { getFlowMenuStyle } from './styles/menu-style';
@injectable()
export class ViewCommonContribution
implements CommandContribution, StylingContribution, ShortcutsContribution
{
@inject(ApplicationShell) protected readonly shell: ApplicationShell;
@inject(ViewService) viewService: ViewService;
@inject(NavigationService) navigationService: NavigationService;
@inject(CommandRegistryFactory) commandFactory: () => CommandRegistry;
get commandService(): CommandRegistry {
return this.commandFactory();
}
@inject(WidgetOpenHandler) protected readonly openHandler: WidgetOpenHandler;
registerStyle({ add }: Collector, { getColor }: ColorTheme): void {
add(`
${getFlowMenuStyle(getColor)}
.flow-hover {
color: ${getColor('flowide.color.base.text.0')};
background: ${getColor('flowide.color.base.bg.0')};
border: 2px solid ${getColor('flowide.color.base.border')};
border-radius: 6px;
padding: 4px;
}
.flowide-container {
height: 100%;
}
.flowide-container .debug-bar-widget-container {
position: fixed;
width: fit-content;
height: fit-content;
}
.flowide-container .lm-Widget {
width: 100%;
height: 100%;
}
.flowide-container .flow-tab-icon-label {
display: flex;
align-items: center;
width: 100%;
overflow: hidden;
}
.flowide-container .flow-tab-icon-label .flow-TabBar-tabLabel-text {
overflow: hidden;
text-overflow: ellipsis;
word-break: keep-all;
white-space: nowrap;
}
.flowide-container .lm-TabBar-tabCloseIcon:hover {
cursor: pointer;
}
.flowide-container .lm-TabBar-content {
width: fit-content;
border: 0;
}
.flowide-container .lm-DockPanel-handle {
background: ${getColor('flowide.color.base.border')};
}
.flowide-container .lm-TabBar {
color: ${getColor('flowide.color.base.text.2')};
background: ${getColor('flowide.color.base.fill.0')};
display: flex;
height: 24px;
}
.flowide-container .lm-TabBar-tab {
background: transparent;
border: 0;
min-width: 125px;
max-height: 24px;
line-height: 24px;
}
.flowide-container .lm-TabBar-tab:hover {
color: ${getColor('flowide.color.base.text.0')};
background: ${getColor('flowide.color.base.fill.0')};
}
.flowide-container .lm-DockPanel-handle[data-orientation="vertical"] {
min-height: 1px;
z-index: 3;
}
.flowide-container .lm-DockPanel-handle[data-orientation="vertical"]:hover {
background: ${getColor('flowide.color.base.primary.hover')};
min-height: 4px;
}
.flowide-container .lm-DockPanel-handle[data-orientation="vertical"]:active {
background: ${getColor('flowide.color.base.primary')};
min-height: 4px;
}
.flowide-container .lm-DockPanel-handle[data-orientation="horizontal"] {
min-width: 1px;
}
.flowide-container .lm-DockPanel-handle[data-orientation="horizontal"]:hover {
background: ${getColor('flowide.color.base.primary.hover')};
min-width: 4px;
}
.flowide-container .lm-DockPanel-handle[data-orientation="horizontal"]:active {
background: ${getColor('flowide.color.base.primary')};
min-width: 4px;
}
.flowide-container .lm-TabBar-tab.lm-mod-current {
background: ${getColor('flowide.color.base.bg.0')};
color: ${getColor('flowide.color.base.text.0')};
transform: unset;
position: relative;
}
.flowide-container #${MAIN_PANEL_ID} .lm-TabBar-tab.lm-mod-current::before {
position: absolute;
top: 0;
left: 0;
content: "";
width: 100%;
height: 2px;
background: ${getColor('flowide.color.base.primary')};
}
.flowide-container #${BOTTOM_PANEL_ID} .lm-TabBar-tab.lm-mod-current::before {
position: absolute;
bottom: 0;
left: 0;
content: "";
width: 100%;
height: 2px;
background: ${getColor('flowide.color.base.primary')};
}
.flowide-container .lm-TabBar-tabCloseIcon.saving:before {
content: "\\f111";
}
.flowide-container .lm-TabBar-tabCloseIcon.saving:hover:before {
content: "\\f00d";
}
.flowide-container .${CUSTOM_TAB_BAR_CONTAINER} {
display: flex;
width: 100%;
height: 100%;
justify-content: space-between;
}
.flowide-container .${TAB_BAR_SCROLL_CONTAINER} {
flex-grow: 1;
position: relative;
overflow: hidden;
}
.flowide-container .${TAB_BAR_SCROLL_CONTAINER} .ide-ps__rail-x {
z-index: 999999;
user-select: none;
pointer-events: none;
}
.flowide-container .${TAB_BAR_SCROLL_CONTAINER} .ide-ps__thumb-x {
height: 2px;
}
.flowide-container .${TAB_BAR_ACTION_CONTAINER} {
flex-shrink: 0;
height: 100%;
}
.flowide-container .${TAB_BAR_ACTION_CONTAINER} .${TAB_BAR_TOOLBAR} {
display: flex;
height: 100%;
}
.flowide-container .${TAB_BAR_ACTION_CONTAINER} .${TAB_BAR_TOOLBAR_ITEM} {
display: flex;
justify-content: center;
align-items: center;
margin-right: 4px;
}
${getPanelStyle(getColor)}
}`);
}
registerCommands(commands: CommandRegistry): void {
commands.registerCommand(
{
id: Command.Default.VIEW_CLOSE_ALL_WIDGET,
label: 'Close All Tab',
},
{
execute: () => {
const parentWidget = this.shell.currentWidget?.parent;
const widgets = (parentWidget as FlowDockPanel).tabBars();
let titles: CustomTitleType[] = [];
for (const customTabBar of widgets) {
titles = titles.concat(customTabBar.titles as CustomTitleType[]);
}
this.commandService.executeCommand(
Command.Default.VIEW_SAVING_WIDGET_CLOSE_CONFIRM,
titles,
);
},
},
);
commands.registerCommand(
{
id: Command.Default.VIEW_CLOSE_CURRENT_WIDGET,
label: 'Close Current Tab',
shortLabel: 'Close',
},
{
execute: widget => {
const closeWidget = widget || this.shell.currentWidget;
if (closeWidget) {
this.commandService.executeCommand(
Command.Default.VIEW_SAVING_WIDGET_CLOSE_CONFIRM,
[closeWidget.title],
);
}
},
},
);
commands.registerCommand(
{
id: Command.Default.VIEW_REOPEN_LAST_WIDGET,
label: 'Reopen Last Tab',
},
{
execute: () => {
const lastCloseUri = this.shell.closeWidgetUriStack.pop();
if (lastCloseUri) {
this.openHandler.open(lastCloseUri);
}
},
},
);
commands.registerCommand(
{
id: Command.Default.VIEW_CLOSE_OTHER_WIDGET,
label: 'Close Other Tab',
},
{
execute: widget => {
try {
const currentWidget = widget || this.shell.currentWidget;
const parentWidget = currentWidget?.parent;
if (!parentWidget) {
return;
}
const titles: CustomTitleType[] = [];
const widgets = (parentWidget as FlowDockPanel).tabBars();
for (const customTabBar of widgets) {
[...customTabBar.titles].map(title => {
if (title.owner !== currentWidget) {
titles.push(title as CustomTitleType);
}
});
}
this.commandService.executeCommand(
Command.Default.VIEW_SAVING_WIDGET_CLOSE_CONFIRM,
titles,
);
} catch (e) {
console.error(e);
}
},
},
);
commands.registerCommand(
{
id: Command.Default.VIEW_CLOSE_BOTTOM_PANEL,
label: 'Close/Open Bottom Pannel',
},
{
execute: () => {
// 没有 focus 内容的时候默认打开 problem
if (!this.shell.bottomPanel?.currentTitle) {
this.commandService.executeCommand(Command.Default.VIEW_PROBLEMS);
}
this.shell.bottomPanel.setHidden(!this.shell.bottomPanel.isHidden);
},
},
);
commands.registerCommand(
{
id: Command.Default.VIEW_OPEN_NEXT_TAB,
label: 'Open Next Tab',
},
{
execute: () => {
this.viewService.openNextTab();
},
},
);
commands.registerCommand(
{
id: Command.Default.VIEW_OEPN_LAST_TAB,
label: 'Open Last Tab',
},
{
execute: () => {
this.viewService.openLastTab();
},
},
);
commands.registerCommand(
{
id: Command.Default.VIEW_FULL_SCREEN,
label: 'Full Screen',
},
{
execute: this.shell.disableFullScreen
? () => null
: () => {
this.viewService.switchFullScreenMode();
},
},
);
}
registerShortcuts(registry: ShortcutsRegistry): void {
// 关闭当前所有 tab
registry.registerHandlers({
keybinding: 'alt shift w',
commandId: Command.Default.VIEW_CLOSE_ALL_WIDGET,
});
// 打开下一个 tab
registry.registerHandlers({
keybinding: 'alt shift rightarrow',
commandId: Command.Default.VIEW_OPEN_NEXT_TAB,
preventDefault: true,
});
// 打开上一个 tab
registry.registerHandlers({
keybinding: 'alt shift leftarrow',
commandId: Command.Default.VIEW_OEPN_LAST_TAB,
preventDefault: true,
});
// 关闭当前 tab
registry.registerHandlers({
keybinding: 'alt w',
commandId: Command.Default.VIEW_CLOSE_CURRENT_WIDGET,
});
// 打开刚刚关闭当前 tab
registry.registerHandlers({
keybinding: 'alt shift t',
commandId: Command.Default.VIEW_REOPEN_LAST_WIDGET,
});
// 关闭除了当前打开的 tab 以外的所有 tab
registry.registerHandlers({
keybinding: 'meta alt t',
commandId: Command.Default.VIEW_CLOSE_OTHER_WIDGET,
});
// 关闭除了当前打开的 tab 以外的所有 tab
registry.registerHandlers({
keybinding: 'meta j',
commandId: Command.Default.VIEW_CLOSE_BOTTOM_PANEL,
});
// 全屏模式
registry.registerHandlers({
keybinding: 'alt f',
commandId: Command.Default.VIEW_FULL_SCREEN,
});
}
}

View File

@@ -0,0 +1,29 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { type ViewPluginOptions } from '../types/view';
export interface ViewOptionRegisterService {
register: (options: Partial<ViewPluginOptions>) => void;
}
interface ViewContribution {
registerView(service: ViewOptionRegisterService): void;
}
const ViewContribution = Symbol('ViewContribution');
export { ViewContribution };

View File

@@ -0,0 +1,217 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { type interfaces } from 'inversify';
// import { bindContributionProvider } from '@flowgram-adapter/common';
import {
bindContributionProvider,
bindContributions,
} from '@flowgram-adapter/common';
import {
CommandContribution,
definePluginCreator,
IDERendererProvider,
OpenHandler,
ShortcutsContribution,
StylingContribution,
EventService,
Command,
domEditable,
} from '@coze-project-ide/core';
import { WidgetManager } from './widget-manager';
import { WidgetOpenHandler } from './widget/widget-open-handler';
import { WidgetFactory } from './widget/widget-factory';
import { TabBarToolbar, TabBarToolbarFactory } from './widget/tab-bar/toolbar';
import {
TabBarRenderer,
TabBarRendererFactory,
} from './widget/tab-bar/tab-renderer';
import { CustomTabBar, TabBarFactory } from './widget/tab-bar/custom-tabbar';
import { DebugBarWidget } from './widget/react-widgets/debug-bar-widget';
import {
CustomRenderWidget,
CustomRenderWidgetFactory,
} from './widget/react-widgets/custom-render-widget-factory';
import { type ReactWidget } from './widget/react-widget';
import {
SidePanelHandler,
SidePanelHandlerFactory,
} from './widget/handlers/side-panel-handler';
import { DockPanelRendererFactory } from './widget/dock-panel-renderer-factory';
import { DockPanelRenderer } from './widget/dock-panel-renderer';
import { FlowDockPanel } from './widget/dock-panel';
import { ViewRenderer } from './view-renderer';
import { ViewManager } from './view-manager';
import { type ViewPluginOptions, type ToolbarAlign } from './types';
import {
ApplicationShell,
CustomPreferenceContribution,
LayoutRestorer,
} from './shell';
import { ViewService } from './services/view-service';
import { HoverService } from './services/hover-service';
import { DragService } from './services/drag-service';
import { DebugService } from './services/debug-service';
import { type DockPanel } from './lumino/widgets';
import { MenuService } from './contributions/context-menu';
import {
bindActivityBarView,
ViewCommonContribution,
ViewContribution,
} from './contributions';
import { ViewOptions } from './constants/view-options';
import { MAIN_PANEL_ID } from './constants';
const DefaultFallbackRender = () => <div>Something went wrong.</div>;
/**
* 点位背景插件
*/
export const createViewPlugin = definePluginCreator<ViewPluginOptions>({
onBind: ({ bind }, opts) => {
bind(ViewManager).toSelf().inSingletonScope();
bind(WidgetManager).toSelf().inSingletonScope();
bind(ViewRenderer).toSelf().inSingletonScope();
bind(ViewOptions).toConstantValue({
widgetFallbackRender: DefaultFallbackRender,
...opts,
});
bind(ApplicationShell).toSelf().inSingletonScope();
bind(LayoutRestorer).toSelf().inSingletonScope();
bindContributionProvider(bind, WidgetFactory);
bindContributionProvider(bind, ViewContribution);
bindContributionProvider(bind, CustomPreferenceContribution);
bindContributions(bind, WidgetOpenHandler, [OpenHandler]);
bind(HoverService).toSelf().inSingletonScope();
bind(DragService).toSelf().inSingletonScope();
bind(ViewService).toSelf().inSingletonScope();
bind(DebugService).toSelf().inSingletonScope();
bind(DebugBarWidget).toSelf().inSingletonScope();
bind(SidePanelHandlerFactory).toAutoFactory(SidePanelHandler);
bind(SidePanelHandler).toSelf();
bind(CustomRenderWidgetFactory).toFactory(
_context => (childContainer: interfaces.Container) => {
childContainer.bind(CustomRenderWidget).toSelf().inSingletonScope();
return childContainer.get(CustomRenderWidget);
},
);
bind(DockPanelRendererFactory).toFactory(context => () => {
const childContainer = context.container.createChild();
childContainer.bind(DockPanelRenderer).toSelf().inSingletonScope();
childContainer.bind(CustomTabBar).toSelf().inSingletonScope();
childContainer.bind(TabBarFactory).toFactory(context => () => {
const container = context.container.createChild();
container.bind(CustomTabBar).toSelf().inSingletonScope();
return container.get(CustomTabBar);
});
childContainer
.bind(TabBarToolbarFactory)
.toFactory(context => (align?: ToolbarAlign) => {
const container = context.container.createChild();
container.bind(TabBarToolbar).toSelf().inSingletonScope();
const toolbar = container.get(TabBarToolbar);
toolbar.initAlign(align);
return toolbar;
});
childContainer.bind(TabBarRendererFactory).toFactory(context => () => {
const container = context.container.createChild();
container.bind(TabBarRenderer).toSelf().inSingletonScope();
return container.get(TabBarRenderer);
});
return childContainer.get(DockPanelRenderer);
});
bind(IDERendererProvider)
.toDynamicValue(ctx => {
const shell = ctx.container.get(ApplicationShell);
return ctx.container.get(ViewRenderer).toReactComponent(shell);
})
.inSingletonScope();
bindContributions(bind, ViewCommonContribution, [
CommandContribution,
StylingContribution,
ShortcutsContribution,
]);
bind(FlowDockPanel.Factory).toFactory(
() => (options?: DockPanel.IOptions) => new FlowDockPanel(options),
);
bindActivityBarView(bind);
},
onInit: async (ctx, opts) => {
const viewManager = ctx.get<ViewManager>(ViewManager);
await viewManager.init(opts);
},
// 页面渲染完成后 attach dom
onLayoutInit: async (ctx, opts) => {
// 预设 contextmenu
if (!opts.presetConfig?.disableContextMenu) {
const menuService = ctx.container.get<MenuService>(MenuService);
menuService.addMenuItem({
command: Command.Default.VIEW_CLOSE_ALL_WIDGET,
selector: '.lm-TabBar-tab',
});
menuService.addMenuItem({
command: Command.Default.VIEW_CLOSE_OTHER_WIDGET,
selector: '.lm-TabBar-tab',
});
menuService.addMenuItem({
command: Command.Default.VIEW_FULL_SCREEN,
selector: '.lm-TabBar-tab',
filter: (widget: ReactWidget) => {
const isMainPanel = widget?.parent?.id === MAIN_PANEL_ID;
return isMainPanel;
},
});
menuService.addMenuItem({
command: Command.Default.VIEW_CLOSE_CURRENT_WIDGET,
selector: '.lm-TabBar-tab',
});
}
const viewManager = ctx.get<ViewManager>(ViewManager);
await viewManager.attach(opts);
const eventService = ctx.container.get<EventService>(EventService);
const menuService = ctx.container.get<MenuService>(MenuService);
// 劫持全局的 contextmenu
eventService.listenGlobalEvent('contextmenu', (e: React.MouseEvent) => {
if (domEditable(e.target as HTMLElement)) {
return;
}
const hasMenu = menuService.open(e);
if (!opts.presetConfig?.disableContextMenu || hasMenu) {
// 在 ide 内部永远阻塞右键
e.stopPropagation();
e.preventDefault();
}
});
},
onDispose: ctx => {
const layoutRestorer = ctx.get<LayoutRestorer>(LayoutRestorer);
layoutRestorer.storeLayout();
},
});

View File

@@ -0,0 +1,22 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { useCurrentWidget } from './use-current-widget';
export { useCurrentWidgetFromArea } from './use-current-widget-from-area';
export {
useCurrentResource,
CurrentResourceContext,
} from './use-current-resource';

View File

@@ -0,0 +1,43 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import {
type Resource,
useIDEService,
ResourceService,
} from '@coze-project-ide/core';
import { useCurrentWidget } from './use-current-widget';
export const CurrentResourceContext = React.createContext<Resource | undefined>(
undefined,
);
export function useCurrentResource<T extends Resource>(): T {
const currentResource = React.useContext(CurrentResourceContext);
if (currentResource) {
return currentResource as T;
}
const resourceService = useIDEService<ResourceService>(ResourceService);
const widget = useCurrentWidget();
const uri = widget.getResourceURI();
if (!uri) {
throw new Error('Cannot get uri from widget');
}
return resourceService.get(uri);
}

View File

@@ -0,0 +1,36 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useEffect } from 'react';
import { useIDEService, useRefresh } from '@coze-project-ide/core';
import { type ReactWidget } from '../widget/react-widget';
import { type LayoutPanelType } from '../types';
import { ApplicationShell } from '../shell/application-shell';
export function useCurrentWidgetFromArea<T extends ReactWidget>(
area: LayoutPanelType.MAIN_PANEL | LayoutPanelType.BOTTOM_PANEL,
): T | undefined {
const shell = useIDEService<ApplicationShell>(ApplicationShell);
const refresh = useRefresh();
useEffect(() => {
const dispose = shell.onCurrentWidgetChange(() => {
refresh();
});
return () => dispose.dispose();
}, [shell]);
return shell.getCurrentWidget(area) as T;
}

View File

@@ -0,0 +1,32 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useContext } from 'react';
import { type ReactWidget, ReactWidgetContext } from '../widget/react-widget';
export function useCurrentWidget<T extends ReactWidget>(): T {
const widget = useContext<ReactWidget | undefined>(ReactWidgetContext);
if (widget?.wrapperWidget) {
return widget?.wrapperWidget as T;
}
if (!widget) {
throw new Error(
'[useCurrentWidget] Undefined react widget from ide context',
);
}
return widget as T;
}

View File

@@ -0,0 +1,162 @@
/*
* Container style
*/
.ide-ps {
overflow: hidden !important;
overflow-anchor: none;
-ms-overflow-style: none;
touch-action: auto;
-ms-touch-action: auto;
}
/*
* Scrollbar rail styles
*/
.ide-ps__rail-x {
display: none;
opacity: 0;
transition: background-color .2s linear, opacity .2s linear;
-webkit-transition: background-color .2s linear, opacity .2s linear;
height: 15px;
/* there must be 'bottom' or 'top' for ide-ps__rail-x */
bottom: 0px;
/* please don't change 'position' */
position: absolute;
}
.ide-ps__rail-y {
display: none;
opacity: 0;
transition: background-color .2s linear, opacity .2s linear;
-webkit-transition: background-color .2s linear, opacity .2s linear;
width: 15px;
/* there must be 'right' or 'left' for ide-ps__rail-y */
right: 0;
/* please don't change 'position' */
position: absolute;
}
.ide-ps--active-x > .ide-ps__rail-x,
.ide-ps--active-y > .ide-ps__rail-y {
display: block;
background-color: transparent;
}
.ide-ps:hover > .ide-ps__rail-x,
.ide-ps:hover > .ide-ps__rail-y,
.ide-ps--focus > .ide-ps__rail-x,
.ide-ps--focus > .ide-ps__rail-y,
.ide-ps--scrolling-x > .ide-ps__rail-x,
.ide-ps--scrolling-y > .ide-ps__rail-y {
opacity: 0.6;
}
.ide-ps .ide-ps__rail-x:hover,
.ide-ps .ide-ps__rail-y:hover,
.ide-ps .ide-ps__rail-x:focus,
.ide-ps .ide-ps__rail-y:focus,
.ide-ps .ide-ps__rail-x.ide-ps--clicking,
.ide-ps .ide-ps__rail-y.ide-ps--clicking {
opacity: 0.9;
}
/*
* Scrollbar thumb styles
*/
.ide-ps__thumb-x {
background-color: #aaa;
border-radius: 6px;
transition: background-color .2s linear, height .2s ease-in-out;
-webkit-transition: background-color .2s linear, height .2s ease-in-out;
height: 6px;
/* there must be 'bottom' for ide-ps__thumb-x */
bottom: 2px;
/* please don't change 'position' */
position: absolute;
}
.ide-ps__thumb-y {
background-color: #aaa;
border-radius: 6px;
transition: background-color .2s linear, width .2s ease-in-out;
-webkit-transition: background-color .2s linear, width .2s ease-in-out;
width: 6px;
/* there must be 'right' for ide-ps__thumb-y */
right: 2px;
/* please don't change 'position' */
position: absolute;
}
.ide-ps__rail-x:hover > .ide-ps__thumb-x,
.ide-ps__rail-x:focus > .ide-ps__thumb-x,
.ide-ps__rail-x.ide-ps--clicking .ide-ps__thumb-x {
background-color: #999;
height: 11px;
}
.ide-ps__rail-y:hover > .ide-ps__thumb-y,
.ide-ps__rail-y:focus > .ide-ps__thumb-y,
.ide-ps__rail-y.ide-ps--clicking .ide-ps__thumb-y {
background-color: #999;
width: 11px;
}
/* MS supports */
@supports (-ms-overflow-style: none) .ide-ps {
overflow: auto !important;
}
@media screen and (-ms-high-contrast: active), (-ms-high-contrast: none) .ide-ps {
overflow: auto !important;
}
.lm-Widget::-webkit-scrollbar {
height: 10px;
width: 10px;
background: transparent;
}
.lm-Widget::-webkit-scrollbar:hover {
background: transparent;
}
.lm-Widget::-webkit-scrollbar-thumb {
background: transparent;
}
.lm-Widget::-webkit-scrollbar-corner {
background: transparent;
}
.lm-cursor-backdrop {
top: 0px;
left: 0px;
position: fixed;
width: 200px;
height: 200px;
margin-top: -100px;
margin-left: -100px;
will-change: transform;
z-index: 100;
scrollbar-width: none;
-ms-overflow-style: none;
overflow: scroll;
}
.lm-cursor-backdrop::after {
content: '';
height: 1200px;
width: 1200px;
display: block;
}
.lm-cursor-backdrop::-webkit-scrollbar {
display: none;
}
.lm-mod-drag-image {
top: 0px;
left: 0px;
will-change: transform;
}

View File

@@ -0,0 +1,81 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import PerfectScrollbar from './components/scroll-bar';
export { ReactWidget, ReactWidgetContext } from './widget/react-widget';
export {
LayoutPanelType,
ToolbarAlign,
type ViewPluginOptions,
type CustomTitleType,
type CustomTitleChanged,
type PresetConfigType,
} from './types';
export { ViewManager } from './view-manager';
export { WidgetManager } from './widget-manager';
export { createViewPlugin } from './create-view-plugin';
export { createContextMenuPlugin } from './contributions/context-menu';
export {
VIEW_CONTAINER_CLASS_NAME,
HOVER_TOOLTIP_LABEL,
DEBUG_BAR_DRAGGABLE,
DISABLE_HANDLE_EVENT,
} from './constants';
export { WidgetFactory, type ToolbarItem } from './widget/widget-factory';
export { HoverService } from './services/hover-service';
export { DragService, type DragPropsType } from './services/drag-service';
export { MenuService } from './contributions/context-menu';
export { ViewService } from './services/view-service';
export { DebugService } from './services/debug-service';
export { FlowDockPanel } from './widget/dock-panel';
import '@vscode/codicons/dist/codicon.css';
import './index.css';
export {
ViewContribution,
type ViewOptionRegisterService,
} from './contributions/view-contribution';
export {
useCurrentWidget,
useCurrentWidgetFromArea,
useCurrentResource,
CurrentResourceContext,
} from './hooks';
export {
Widget,
BoxLayout,
SplitLayout,
SplitPanel,
BoxPanel,
DockLayout,
TabBar,
} from './lumino/widgets';
export {
StatefulWidget,
LayoutRestorer,
CustomPreferenceContribution,
type CustomPreferenceConfig,
} from './shell/layout-restorer';
export { ApplicationShell } from './shell/application-shell';
export { PerfectScrollbar };
export { SplitWidget } from './widget/react-widgets/split-widget';
export { createBoxLayout, createSplitLayout } from './utils';
export { TabBarToolbar } from './widget/tab-bar/toolbar';
export { ACTIVITY_BAR_CONTENT } from './widget/react-widgets/activity-bar-widget';
export { ViewRenderer } from './view-renderer';
export { CustomTabBar } from './widget/tab-bar/custom-tabbar';

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,53 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// Copyright (c) Jupyter Development Team.
// Distributed under the terms of the Modified BSD License.
/*-----------------------------------------------------------------------------
| Copyright (c) 2014-2017, PhosphorJS Contributors
|
| Distributed under the terms of the BSD 3-Clause License.
|
| The full license is in the file LICENSE, distributed with this software.
|----------------------------------------------------------------------------*/
/**
* Chain together several iterables.
*
* @deprecated
*
* @param objects - The iterable objects of interest.
*
* @returns An iterator which yields the values of the iterables
* in the order in which they are supplied.
*
* #### Example
* ```typescript
* import { chain } from '../algorithm';
*
* let data1 = [1, 2, 3];
* let data2 = [4, 5, 6];
*
* let stream = chain(data1, data2);
*
* Array.from(stream); // [1, 2, 3, 4, 5, 6]
* ```
*/
export function* chain<T>(...objects: Iterable<T>[]): IterableIterator<T> {
for (const object of objects) {
yield* object;
}
}

View File

@@ -0,0 +1,44 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// Copyright (c) Jupyter Development Team.
// Distributed under the terms of the Modified BSD License.
/*-----------------------------------------------------------------------------
| Copyright (c) 2014-2017, PhosphorJS Contributors
|
| Distributed under the terms of the BSD 3-Clause License.
|
| The full license is in the file LICENSE, distributed with this software.
|----------------------------------------------------------------------------*/
/**
* Create an empty iterator.
*
* @returns A new iterator which yields nothing.
*
* #### Example
* ```typescript
* import { empty } from '../algorithm';
*
* let stream = empty<number>();
*
* Array.from(stream); // []
* ```
*/
export function* empty<T>(): IterableIterator<T> {
return;
}

View File

@@ -0,0 +1,54 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// Copyright (c) Jupyter Development Team.
// Distributed under the terms of the Modified BSD License.
/*-----------------------------------------------------------------------------
| Copyright (c) 2014-2017, PhosphorJS Contributors
|
| Distributed under the terms of the BSD 3-Clause License.
|
| The full license is in the file LICENSE, distributed with this software.
|----------------------------------------------------------------------------*/
/**
* Enumerate an iterable object.
*
* @param object - The iterable object of interest.
*
* @param start - The starting enum value. The default is `0`.
*
* @returns An iterator which yields the enumerated values.
*
* #### Example
* ```typescript
* import { enumerate } from '../algorithm';
*
* let data = ['foo', 'bar', 'baz'];
*
* let stream = enumerate(data, 1);
*
* Array.from(stream); // [[1, 'foo'], [2, 'bar'], [3, 'baz']]
* ```
*/
export function* enumerate<T>(
object: Iterable<T>,
start = 0,
): IterableIterator<[number, T]> {
for (const value of object) {
yield [start++, value];
}
}

View File

@@ -0,0 +1,57 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// Copyright (c) Jupyter Development Team.
// Distributed under the terms of the Modified BSD License.
/*-----------------------------------------------------------------------------
| Copyright (c) 2014-2017, PhosphorJS Contributors
|
| Distributed under the terms of the BSD 3-Clause License.
|
| The full license is in the file LICENSE, distributed with this software.
|----------------------------------------------------------------------------*/
/**
* Filter an iterable for values which pass a test.
*
* @param object - The iterable object of interest.
*
* @param fn - The predicate function to invoke for each value.
*
* @returns An iterator which yields the values which pass the test.
*
* #### Example
* ```typescript
* import { filter } from '../algorithm';
*
* let data = [1, 2, 3, 4, 5, 6];
*
* let stream = filter(data, value => value % 2 === 0);
*
* Array.from(stream); // [2, 4, 6]
* ```
*/
export function* filter<T>(
object: Iterable<T>,
fn: (value: T, index: number) => boolean,
): IterableIterator<T> {
let index = 0;
for (const value of object) {
if (fn(value, index++)) {
yield value;
}
}
}

View File

@@ -0,0 +1,254 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// Copyright (c) Jupyter Development Team.
// Distributed under the terms of the Modified BSD License.
/*-----------------------------------------------------------------------------
| Copyright (c) 2014-2017, PhosphorJS Contributors
|
| Distributed under the terms of the BSD 3-Clause License.
|
| The full license is in the file LICENSE, distributed with this software.
|----------------------------------------------------------------------------*/
/**
* Find the first value in an iterable which matches a predicate.
*
* @param object - The iterable object to search.
*
* @param fn - The predicate function to apply to the values.
*
* @returns The first matching value, or `undefined` if no matching
* value is found.
*
* #### Complexity
* Linear.
*
* #### Example
* ```typescript
* import { find } from '../algorithm';
*
* interface IAnimal { species: string, name: string };
*
* function isCat(value: IAnimal): boolean {
* return value.species === 'cat';
* }
*
* let data: IAnimal[] = [
* { species: 'dog', name: 'spot' },
* { species: 'cat', name: 'fluffy' },
* { species: 'alligator', name: 'pocho' }
* ];
*
* find(data, isCat).name; // 'fluffy'
* ```
*/
export function find<T>(
object: Iterable<T>,
fn: (value: T, index: number) => boolean,
): T | undefined {
let index = 0;
for (const value of object) {
if (fn(value, index++)) {
return value;
}
}
return undefined;
}
/**
* Find the index of the first value which matches a predicate.
*
* @param object - The iterable object to search.
*
* @param fn - The predicate function to apply to the values.
*
* @returns The index of the first matching value, or `-1` if no
* matching value is found.
*
* #### Complexity
* Linear.
*
* #### Example
* ```typescript
* import { findIndex } from '../algorithm';
*
* interface IAnimal { species: string, name: string };
*
* function isCat(value: IAnimal): boolean {
* return value.species === 'cat';
* }
*
* let data: IAnimal[] = [
* { species: 'dog', name: 'spot' },
* { species: 'cat', name: 'fluffy' },
* { species: 'alligator', name: 'pocho' }
* ];
*
* findIndex(data, isCat); // 1
* ```
*/
export function findIndex<T>(
object: Iterable<T>,
fn: (value: T, index: number) => boolean,
): number {
let index = 0;
for (const value of object) {
if (fn(value, index++)) {
return index - 1;
}
}
return -1;
}
/**
* Find the minimum value in an iterable.
*
* @param object - The iterable object to search.
*
* @param fn - The 3-way comparison function to apply to the values.
* It should return `< 0` if the first value is less than the second.
* `0` if the values are equivalent, or `> 0` if the first value is
* greater than the second.
*
* @returns The minimum value in the iterable. If multiple values are
* equivalent to the minimum, the left-most value is returned. If
* the iterable is empty, this returns `undefined`.
*
* #### Complexity
* Linear.
*
* #### Example
* ```typescript
* import { min } from '../algorithm';
*
* function numberCmp(a: number, b: number): number {
* return a - b;
* }
*
* min([7, 4, 0, 3, 9, 4], numberCmp); // 0
* ```
*/
export function min<T>(
object: Iterable<T>,
fn: (first: T, second: T) => number,
): T | undefined {
let result: T | undefined = undefined;
for (const value of object) {
if (result === undefined) {
result = value;
continue;
}
if (fn(value, result) < 0) {
result = value;
}
}
return result;
}
/**
* Find the maximum value in an iterable.
*
* @param object - The iterable object to search.
*
* @param fn - The 3-way comparison function to apply to the values.
* It should return `< 0` if the first value is less than the second.
* `0` if the values are equivalent, or `> 0` if the first value is
* greater than the second.
*
* @returns The maximum value in the iterable. If multiple values are
* equivalent to the maximum, the left-most value is returned. If
* the iterable is empty, this returns `undefined`.
*
* #### Complexity
* Linear.
*
* #### Example
* ```typescript
* import { max } from '../algorithm';
*
* function numberCmp(a: number, b: number): number {
* return a - b;
* }
*
* max([7, 4, 0, 3, 9, 4], numberCmp); // 9
* ```
*/
export function max<T>(
object: Iterable<T>,
fn: (first: T, second: T) => number,
): T | undefined {
let result: T | undefined = undefined;
for (const value of object) {
if (result === undefined) {
result = value;
continue;
}
if (fn(value, result) > 0) {
result = value;
}
}
return result;
}
/**
* Find the minimum and maximum values in an iterable.
*
* @param object - The iterable object to search.
*
* @param fn - The 3-way comparison function to apply to the values.
* It should return `< 0` if the first value is less than the second.
* `0` if the values are equivalent, or `> 0` if the first value is
* greater than the second.
*
* @returns A 2-tuple of the `[min, max]` values in the iterable. If
* multiple values are equivalent, the left-most values are returned.
* If the iterable is empty, this returns `undefined`.
*
* #### Complexity
* Linear.
*
* #### Example
* ```typescript
* import { minmax } from '../algorithm';
*
* function numberCmp(a: number, b: number): number {
* return a - b;
* }
*
* minmax([7, 4, 0, 3, 9, 4], numberCmp); // [0, 9]
* ```
*/
export function minmax<T>(
object: Iterable<T>,
fn: (first: T, second: T) => number,
): [T, T] | undefined {
let empty = true;
let vmin: T;
let vmax: T;
for (const value of object) {
if (empty) {
vmin = value;
vmax = value;
empty = false;
} else if (fn(value, vmin!) < 0) {
vmin = value;
} else if (fn(value, vmax!) > 0) {
vmax = value;
}
}
return empty ? undefined : [vmin!, vmax!];
}

View File

@@ -0,0 +1,46 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// Copyright (c) Jupyter Development Team.
// Distributed under the terms of the Modified BSD License.
/*-----------------------------------------------------------------------------
| Copyright (c) 2014-2017, PhosphorJS Contributors
|
| Distributed under the terms of the BSD 3-Clause License.
|
| The full license is in the file LICENSE, distributed with this software.
|----------------------------------------------------------------------------*/
/**
* @packageDocumentation
* @module algorithm
*/
export * from './array';
export * from './chain';
export * from './empty';
export * from './enumerate';
export * from './filter';
export * from './find';
export * from './iter';
export * from './map';
export * from './range';
export * from './reduce';
export * from './repeat';
export * from './retro';
export * from './sort';
export * from './stride';
export * from './string';
export * from './take';
export * from './zip';

View File

@@ -0,0 +1,186 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// Copyright (c) Jupyter Development Team.
// Distributed under the terms of the Modified BSD License.
/*-----------------------------------------------------------------------------
| Copyright (c) 2014-2017, PhosphorJS Contributors
|
| Distributed under the terms of the BSD 3-Clause License.
|
| The full license is in the file LICENSE, distributed with this software.
|----------------------------------------------------------------------------*/
/**
* Create an array from an iterable of values.
*
* @deprecated
*
* @param object - The iterable object of interest.
*
* @returns A new array of values from the given object.
*
* #### Example
* ```typescript
* import { toArray } from '../algorithm';
*
* let stream = [1, 2, 3, 4, 5, 6][Symbol.iterator]();
*
* toArray(stream); // [1, 2, 3, 4, 5, 6];
* ```
*/
export function toArray<T>(object: Iterable<T>): T[] {
return Array.from(object);
}
/**
* Create an object from an iterable of key/value pairs.
*
* @param object - The iterable object of interest.
*
* @returns A new object mapping keys to values.
*
* #### Example
* ```typescript
* import { toObject } from '../algorithm';
*
* let data: [string, number][] = [['one', 1], ['two', 2], ['three', 3]];
*
* toObject(data); // { one: 1, two: 2, three: 3 }
* ```
*/
export function toObject<T>(object: Iterable<[string, T]>): {
[key: string]: T;
} {
const result: { [key: string]: T } = {};
for (const [key, value] of object) {
result[key] = value;
}
return result;
}
/**
* Invoke a function for each value in an iterable.
*
* @deprecated
*
* @param object - The iterable object of interest.
*
* @param fn - The callback function to invoke for each value.
*
* #### Notes
* Iteration can be terminated early by returning `false` from the
* callback function.
*
* #### Complexity
* Linear.
*
* #### Example
* ```typescript
* import { each } from '../algorithm';
*
* let data = [5, 7, 0, -2, 9];
*
* each(data, value => { console.log(value); });
* ```
*/
export function each<T>(
object: Iterable<T>,
fn: (value: T, index: number) => boolean | void,
): void {
let index = 0;
for (const value of object) {
if (false === fn(value, index++)) {
return;
}
}
}
/**
* Test whether all values in an iterable satisfy a predicate.
*
* @param object - The iterable object of interest.
*
* @param fn - The predicate function to invoke for each value.
*
* @returns `true` if all values pass the test, `false` otherwise.
*
* #### Notes
* Iteration terminates on the first `false` predicate result.
*
* #### Complexity
* Linear.
*
* #### Example
* ```typescript
* import { every } from '../algorithm';
*
* let data = [5, 7, 1];
*
* every(data, value => value % 2 === 0); // false
* every(data, value => value % 2 === 1); // true
* ```
*/
export function every<T>(
object: Iterable<T>,
fn: (value: T, index: number) => boolean,
): boolean {
let index = 0;
for (const value of object) {
if (false === fn(value, index++)) {
return false;
}
}
return true;
}
/**
* Test whether any value in an iterable satisfies a predicate.
*
* @param object - The iterable object of interest.
*
* @param fn - The predicate function to invoke for each value.
*
* @returns `true` if any value passes the test, `false` otherwise.
*
* #### Notes
* Iteration terminates on the first `true` predicate result.
*
* #### Complexity
* Linear.
*
* #### Example
* ```typescript
* import { some } from '../algorithm';
*
* let data = [5, 7, 1];
*
* some(data, value => value === 7); // true
* some(data, value => value === 3); // false
* ```
*/
export function some<T>(
object: Iterable<T>,
fn: (value: T, index: number) => boolean,
): boolean {
let index = 0;
for (const value of object) {
if (fn(value, index++)) {
return true;
}
}
return false;
}

View File

@@ -0,0 +1,54 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// Copyright (c) Jupyter Development Team.
// Distributed under the terms of the Modified BSD License.
/*-----------------------------------------------------------------------------
| Copyright (c) 2014-2017, PhosphorJS Contributors
|
| Distributed under the terms of the BSD 3-Clause License.
|
| The full license is in the file LICENSE, distributed with this software.
|----------------------------------------------------------------------------*/
/**
* Transform the values of an iterable with a mapping function.
*
* @param object - The iterable object of interest.
*
* @param fn - The mapping function to invoke for each value.
*
* @returns An iterator which yields the transformed values.
*
* #### Example
* ```typescript
* import { map } from '../algorithm';
*
* let data = [1, 2, 3];
*
* let stream = map(data, value => value * 2);
*
* Array.from(stream); // [2, 4, 6]
* ```
*/
export function* map<T, U>(
object: Iterable<T>,
fn: (value: T, index: number) => U,
): IterableIterator<U> {
let index = 0;
for (const value of object) {
yield fn(value, index++);
}
}

View File

@@ -0,0 +1,102 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// Copyright (c) Jupyter Development Team.
// Distributed under the terms of the Modified BSD License.
/*-----------------------------------------------------------------------------
| Copyright (c) 2014-2017, PhosphorJS Contributors
|
| Distributed under the terms of the BSD 3-Clause License.
|
| The full license is in the file LICENSE, distributed with this software.
|----------------------------------------------------------------------------*/
/**
* Create an iterator of evenly spaced values.
*
* @param start - The starting value for the range, inclusive.
*
* @param stop - The stopping value for the range, exclusive.
*
* @param step - The distance between each value.
*
* @returns An iterator which produces evenly spaced values.
*
* #### Notes
* In the single argument form of `range(stop)`, `start` defaults to
* `0` and `step` defaults to `1`.
*
* In the two argument form of `range(start, stop)`, `step` defaults
* to `1`.
*
* #### Example
* ```typescript
* import { range } from '../algorithm';
*
* let stream = range(2, 4);
*
* Array.from(stream); // [2, 3]
* ```
*/
export function* range(
start: number,
stop?: number,
step?: number,
): IterableIterator<number> {
if (stop === undefined) {
stop = start;
start = 0;
step = 1;
} else if (step === undefined) {
step = 1;
}
const length = Private.rangeLength(start, stop, step);
for (let index = 0; index < length; index++) {
yield start + step * index;
}
}
/**
* The namespace for the module implementation details.
*/
namespace Private {
/**
* Compute the effective length of a range.
*
* @param start - The starting value for the range, inclusive.
*
* @param stop - The stopping value for the range, exclusive.
*
* @param step - The distance between each value.
*
* @returns The number of steps need to traverse the range.
*/
export function rangeLength(
start: number,
stop: number,
step: number,
): number {
if (step === 0) {
return Infinity;
}
if (start > stop && step > 0) {
return 0;
}
if (start < stop && step < 0) {
return 0;
}
return Math.ceil((stop - start) / step);
}
}

View File

@@ -0,0 +1,122 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// Copyright (c) Jupyter Development Team.
// Distributed under the terms of the Modified BSD License.
/*-----------------------------------------------------------------------------
| Copyright (c) 2014-2017, PhosphorJS Contributors
|
| Distributed under the terms of the BSD 3-Clause License.
|
| The full license is in the file LICENSE, distributed with this software.
|----------------------------------------------------------------------------*/
/**
* Summarize all values in an iterable using a reducer function.
*
* @param object - The iterable object of interest.
*
* @param fn - The reducer function to invoke for each value.
*
* @param initial - The initial value to start accumulation.
*
* @returns The final accumulated value.
*
* #### Notes
* The `reduce` function follows the conventions of `Array#reduce`.
*
* If the iterator is empty, an initial value is required. That value
* will be used as the return value. If no initial value is provided,
* an error will be thrown.
*
* If the iterator contains a single item and no initial value is
* provided, the single item is used as the return value.
*
* Otherwise, the reducer is invoked for each element in the iterable.
* If an initial value is not provided, the first element will be used
* as the initial accumulated value.
*
* #### Complexity
* Linear.
*
* #### Example
* ```typescript
* import { reduce } from '../algorithm';
*
* let data = [1, 2, 3, 4, 5];
*
* let sum = reduce(data, (a, value) => a + value); // 15
* ```
*/
export function reduce<T>(
object: Iterable<T>,
fn: (accumulator: T, value: T, index: number) => T,
): T;
export function reduce<T, U>(
object: Iterable<T>,
fn: (accumulator: U, value: T, index: number) => U,
initial: U,
): U;
export function reduce<T>(
object: Iterable<T>,
fn: (accumulator: any, value: T, index: number) => any,
initial?: unknown,
): any {
// Setup the iterator and fetch the first value.
const it = object[Symbol.iterator]();
let index = 0;
const first = it.next();
// An empty iterator and no initial value is an error.
if (first.done && initial === undefined) {
throw new TypeError('Reduce of empty iterable with no initial value.');
}
// If the iterator is empty, return the initial value.
if (first.done) {
return initial;
}
// If the iterator has a single item and no initial value, the
// reducer is not invoked and the first item is the return value.
const second = it.next();
if (second.done && initial === undefined) {
return first.value;
}
// If iterator has a single item and an initial value is provided,
// the reducer is invoked and that result is the return value.
if (second.done) {
return fn(initial, first.value, index++);
}
// Setup the initial accumlated value.
let accumulator: any;
if (initial === undefined) {
accumulator = fn(first.value, second.value, index++);
} else {
accumulator = fn(fn(initial, first.value, index++), second.value, index++);
}
// Iterate the rest of the values, updating the accumulator.
let next: IteratorResult<T>;
while (!(next = it.next()).done) {
accumulator = fn(accumulator, next.value, index++);
}
// Return the final accumulated value.
return accumulator;
}

View File

@@ -0,0 +1,73 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// Copyright (c) Jupyter Development Team.
// Distributed under the terms of the Modified BSD License.
/*-----------------------------------------------------------------------------
| Copyright (c) 2014-2017, PhosphorJS Contributors
|
| Distributed under the terms of the BSD 3-Clause License.
|
| The full license is in the file LICENSE, distributed with this software.
|----------------------------------------------------------------------------*/
/**
* Create an iterator which repeats a value a number of times.
*
* @deprecated
*
* @param value - The value to repeat.
*
* @param count - The number of times to repeat the value.
*
* @returns A new iterator which repeats the specified value.
*
* #### Example
* ```typescript
* import { repeat } from '../algorithm';
*
* let stream = repeat(7, 3);
*
* Array.from(stream); // [7, 7, 7]
* ```
*/
export function* repeat<T>(value: T, count: number): IterableIterator<T> {
while (0 < count--) {
yield value;
}
}
/**
* Create an iterator which yields a value a single time.
*
* @deprecated
*
* @param value - The value to wrap in an iterator.
*
* @returns A new iterator which yields the value a single time.
*
* #### Example
* ```typescript
* import { once } from '../algorithm';
*
* let stream = once(7);
*
* Array.from(stream); // [7]
* ```
*/
export function* once<T>(value: T): IterableIterator<T> {
yield value;
}

View File

@@ -0,0 +1,67 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// Copyright (c) Jupyter Development Team.
// Distributed under the terms of the Modified BSD License.
/*-----------------------------------------------------------------------------
| Copyright (c) 2014-2017, PhosphorJS Contributors
|
| Distributed under the terms of the BSD 3-Clause License.
|
| The full license is in the file LICENSE, distributed with this software.
|----------------------------------------------------------------------------*/
/**
* An object which can produce a reverse iterator over its values.
*/
export interface IRetroable<T> {
/**
* Get a reverse iterator over the object's values.
*
* @returns An iterator which yields the object's values in reverse.
*/
retro(): IterableIterator<T>;
}
/**
* Create an iterator for a retroable object.
*
* @param object - The retroable or array-like object of interest.
*
* @returns An iterator which traverses the object's values in reverse.
*
* #### Example
* ```typescript
* import { retro } from '../algorithm';
*
* let data = [1, 2, 3, 4, 5, 6];
*
* let stream = retro(data);
*
* Array.from(stream); // [6, 5, 4, 3, 2, 1]
* ```
*/
export function* retro<T>(
object: IRetroable<T> | ArrayLike<T>,
): IterableIterator<T> {
if (typeof (object as IRetroable<T>).retro === 'function') {
yield* (object as IRetroable<T>).retro();
} else {
for (let index = (object as ArrayLike<T>).length - 1; index > -1; index--) {
yield (object as ArrayLike<T>)[index];
}
}
}

View File

@@ -0,0 +1,97 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// Copyright (c) Jupyter Development Team.
// Distributed under the terms of the Modified BSD License.
/*-----------------------------------------------------------------------------
| Copyright (c) 2014-2017, PhosphorJS Contributors
|
| Distributed under the terms of the BSD 3-Clause License.
|
| The full license is in the file LICENSE, distributed with this software.
|----------------------------------------------------------------------------*/
/**
* Topologically sort an iterable of edges.
*
* @param edges - The iterable object of edges to sort.
* An edge is represented as a 2-tuple of `[fromNode, toNode]`.
*
* @returns The topologically sorted array of nodes.
*
* #### Notes
* If a cycle is present in the graph, the cycle will be ignored and
* the return value will be only approximately sorted.
*
* #### Example
* ```typescript
* import { topologicSort } from '../algorithm';
*
* let data = [
* ['d', 'e'],
* ['c', 'd'],
* ['a', 'b'],
* ['b', 'c']
* ];
*
* topologicSort(data); // ['a', 'b', 'c', 'd', 'e']
* ```
*/
export function topologicSort<T>(edges: Iterable<[T, T]>): T[] {
// Setup the shared sorting state.
const sorted: T[] = [];
const visited = new Set<T>();
const graph = new Map<T, T[]>();
// Add the edges to the graph.
for (const edge of edges) {
addEdge(edge);
}
// Visit each node in the graph.
for (const [k] of graph) {
visit(k);
}
// Return the sorted results.
return sorted;
// Add an edge to the graph.
function addEdge(edge: [T, T]): void {
const [fromNode, toNode] = edge;
const children = graph.get(toNode);
if (children) {
children.push(fromNode);
} else {
graph.set(toNode, [fromNode]);
}
}
// Recursively visit the node.
function visit(node: T): void {
if (visited.has(node)) {
return;
}
visited.add(node);
const children = graph.get(node);
if (children) {
for (const child of children) {
visit(child);
}
}
sorted.push(node);
}
}

View File

@@ -0,0 +1,58 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// Copyright (c) Jupyter Development Team.
// Distributed under the terms of the Modified BSD License.
/*-----------------------------------------------------------------------------
| Copyright (c) 2014-2017, PhosphorJS Contributors
|
| Distributed under the terms of the BSD 3-Clause License.
|
| The full license is in the file LICENSE, distributed with this software.
|----------------------------------------------------------------------------*/
/**
* Iterate over an iterable using a stepped increment.
*
* @param object - The iterable object of interest.
*
* @param step - The distance to step on each iteration. A value
* of less than `1` will behave the same as a value of `1`.
*
* @returns An iterator which traverses the iterable step-wise.
*
* #### Example
* ```typescript
* import { stride } from '../algorithm';
*
* let data = [1, 2, 3, 4, 5, 6];
*
* let stream = stride(data, 2);
*
* Array.from(stream); // [1, 3, 5];
* ```
*/
export function* stride<T>(
object: Iterable<T>,
step: number,
): IterableIterator<T> {
let count = 0;
for (const value of object) {
if (0 === count++ % step) {
yield value;
}
}
}

View File

@@ -0,0 +1,240 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// Copyright (c) Jupyter Development Team.
// Distributed under the terms of the Modified BSD License.
/*-----------------------------------------------------------------------------
| Copyright (c) 2014-2017, PhosphorJS Contributors
|
| Distributed under the terms of the BSD 3-Clause License.
|
| The full license is in the file LICENSE, distributed with this software.
|----------------------------------------------------------------------------*/
/**
* The namespace for string-specific algorithms.
*/
export namespace StringExt {
/**
* Find the indices of characters in a source text.
*
* @param source - The source text which should be searched.
*
* @param query - The characters to locate in the source text.
*
* @param start - The index to start the search.
*
* @returns The matched indices, or `null` if there is no match.
*
* #### Complexity
* Linear on `sourceText`.
*
* #### Notes
* In order for there to be a match, all of the characters in `query`
* **must** appear in `source` in the order given by `query`.
*
* Characters are matched using strict `===` equality.
*/
export function findIndices(
source: string,
query: string,
start = 0,
): number[] | null {
const indices = new Array<number>(query.length);
for (let i = 0, j = start, n = query.length; i < n; ++i, ++j) {
j = source.indexOf(query[i], j);
if (j === -1) {
return null;
}
indices[i] = j;
}
return indices;
}
/**
* The result of a string match function.
*/
export interface IMatchResult {
/**
* A score which indicates the strength of the match.
*
* The documentation of a given match function should specify
* whether a lower or higher score is a stronger match.
*/
score: number;
/**
* The indices of the matched characters in the source text.
*
* The indices will appear in increasing order.
*/
indices: number[];
}
/**
* A string matcher which uses a sum-of-squares algorithm.
*
* @param source - The source text which should be searched.
*
* @param query - The characters to locate in the source text.
*
* @param start - The index to start the search.
*
* @returns The match result, or `null` if there is no match.
* A lower `score` represents a stronger match.
*
* #### Complexity
* Linear on `sourceText`.
*
* #### Notes
* This scoring algorithm uses a sum-of-squares approach to determine
* the score. In order for there to be a match, all of the characters
* in `query` **must** appear in `source` in order. The index of each
* matching character is squared and added to the score. This means
* that early and consecutive character matches are preferred, while
* late matches are heavily penalized.
*/
export function matchSumOfSquares(
source: string,
query: string,
start = 0,
): IMatchResult | null {
const indices = findIndices(source, query, start);
if (!indices) {
return null;
}
let score = 0;
for (let i = 0, n = indices.length; i < n; ++i) {
const j = indices[i] - start;
score += j * j;
}
return { score, indices };
}
/**
* A string matcher which uses a sum-of-deltas algorithm.
*
* @param source - The source text which should be searched.
*
* @param query - The characters to locate in the source text.
*
* @param start - The index to start the search.
*
* @returns The match result, or `null` if there is no match.
* A lower `score` represents a stronger match.
*
* #### Complexity
* Linear on `sourceText`.
*
* #### Notes
* This scoring algorithm uses a sum-of-deltas approach to determine
* the score. In order for there to be a match, all of the characters
* in `query` **must** appear in `source` in order. The delta between
* the indices are summed to create the score. This means that groups
* of matched characters are preferred, while fragmented matches are
* penalized.
*/
export function matchSumOfDeltas(
source: string,
query: string,
start = 0,
): IMatchResult | null {
const indices = findIndices(source, query, start);
if (!indices) {
return null;
}
let score = 0;
let last = start - 1;
for (let i = 0, n = indices.length; i < n; ++i) {
const j = indices[i];
score += j - last - 1;
last = j;
}
return { score, indices };
}
/**
* Highlight the matched characters of a source text.
*
* @param source - The text which should be highlighted.
*
* @param indices - The indices of the matched characters. They must
* appear in increasing order and must be in bounds of the source.
*
* @param fn - The function to apply to the matched chunks.
*
* @returns An array of unmatched and highlighted chunks.
*/
export function highlight<T>(
source: string,
indices: ReadonlyArray<number>,
fn: (chunk: string) => T,
): Array<string | T> {
// Set up the result array.
const result: Array<string | T> = [];
// Set up the counter variables.
let k = 0;
let last = 0;
const n = indices.length;
// Iterator over each index.
while (k < n) {
// Set up the chunk indices.
const i = indices[k];
let j = indices[k];
// Advance the right chunk index until it's non-contiguous.
while (++k < n && indices[k] === j + 1) {
j++;
}
// Extract the unmatched text.
if (last < i) {
result.push(source.slice(last, i));
}
// Extract and highlight the matched text.
if (i < j + 1) {
result.push(fn(source.slice(i, j + 1)));
}
// Update the last visited index.
last = j + 1;
}
// Extract any remaining unmatched text.
if (last < source.length) {
result.push(source.slice(last));
}
// Return the highlighted result.
return result;
}
/**
* A 3-way string comparison function.
*
* @param a - The first string of interest.
*
* @param b - The second string of interest.
*
* @returns `-1` if `a < b`, else `1` if `a > b`, else `0`.
*/
export function cmp(a: string, b: string): number {
return a < b ? -1 : a > b ? 1 : 0;
}
}

View File

@@ -0,0 +1,62 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// Copyright (c) Jupyter Development Team.
// Distributed under the terms of the Modified BSD License.
/*-----------------------------------------------------------------------------
| Copyright (c) 2014-2017, PhosphorJS Contributors
|
| Distributed under the terms of the BSD 3-Clause License.
|
| The full license is in the file LICENSE, distributed with this software.
|----------------------------------------------------------------------------*/
/**
* Take a fixed number of items from an iterable.
*
* @param object - The iterable object of interest.
*
* @param count - The number of items to take from the iterable.
*
* @returns An iterator which yields the specified number of items
* from the source iterable.
*
* #### Notes
* The returned iterator will exhaust early if the source iterable
* contains an insufficient number of items.
*
* #### Example
* ```typescript
* import { take } from '../algorithm';
*
* let stream = take([5, 4, 3, 2, 1, 0, -1], 3);
*
* Array.from(stream); // [5, 4, 3]
* ```
*/
export function* take<T>(
object: Iterable<T>,
count: number,
): IterableIterator<T> {
if (count < 1) {
return;
}
const it = object[Symbol.iterator]();
let item: IteratorResult<T>;
while (0 < count-- && !(item = it.next()).done) {
yield item.value;
}
}

View File

@@ -0,0 +1,55 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// Copyright (c) Jupyter Development Team.
// Distributed under the terms of the Modified BSD License.
/*-----------------------------------------------------------------------------
| Copyright (c) 2014-2017, PhosphorJS Contributors
|
| Distributed under the terms of the BSD 3-Clause License.
|
| The full license is in the file LICENSE, distributed with this software.
|----------------------------------------------------------------------------*/
import { every } from './iter';
/**
* Iterate several iterables in lockstep.
*
* @param objects - The iterable objects of interest.
*
* @returns An iterator which yields successive tuples of values where
* each value is taken in turn from the provided iterables. It will
* be as long as the shortest provided iterable.
*
* #### Example
* ```typescript
* import { zip } from '../algorithm';
*
* let data1 = [1, 2, 3];
* let data2 = [4, 5, 6];
*
* let stream = zip(data1, data2);
*
* Array.from(stream); // [[1, 4], [2, 5], [3, 6]]
* ```
*/
export function* zip<T>(...objects: Iterable<T>[]): IterableIterator<T[]> {
const iters = objects.map(obj => obj[Symbol.iterator]());
let tuple = iters.map(it => it.next());
for (; every(tuple, item => !item.done); tuple = iters.map(it => it.next())) {
yield tuple.map(item => item.value);
}
}

View File

@@ -0,0 +1,30 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// Copyright (c) Jupyter Development Team.
// Distributed under the terms of the Modified BSD License.
/*-----------------------------------------------------------------------------
| Copyright (c) 2014-2017, PhosphorJS Contributors
|
| Distributed under the terms of the BSD 3-Clause License.
|
| The full license is in the file LICENSE, distributed with this software.
|----------------------------------------------------------------------------*/
/**
* @packageDocumentation
* @module collections
*/
export * from './linkedlist';

View File

@@ -0,0 +1,587 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// Copyright (c) Jupyter Development Team.
// Distributed under the terms of the Modified BSD License.
/*-----------------------------------------------------------------------------
| Copyright (c) 2014-2017, PhosphorJS Contributors
|
| Distributed under the terms of the BSD 3-Clause License.
|
| The full license is in the file LICENSE, distributed with this software.
|----------------------------------------------------------------------------*/
import { type IRetroable } from '../algorithm';
/**
* A generic doubly-linked list.
*/
export class LinkedList<T> implements Iterable<T>, IRetroable<T> {
/**
* Whether the list is empty.
*
* #### Complexity
* Constant.
*/
get isEmpty(): boolean {
return this._size === 0;
}
/**
* The size of the list.
*
* #### Complexity
* `O(1)`
*
* #### Notes
* This is equivalent to `length`.
*/
get size(): number {
return this._size;
}
/**
* The length of the list.
*
* #### Complexity
* Constant.
*
* #### Notes
* This is equivalent to `size`.
*
* This property is deprecated.
*/
get length(): number {
return this._size;
}
/**
* The first value in the list.
*
* This is `undefined` if the list is empty.
*
* #### Complexity
* Constant.
*/
get first(): T | undefined {
return this._first ? this._first.value : undefined;
}
/**
* The last value in the list.
*
* This is `undefined` if the list is empty.
*
* #### Complexity
* Constant.
*/
get last(): T | undefined {
return this._last ? this._last.value : undefined;
}
/**
* The first node in the list.
*
* This is `null` if the list is empty.
*
* #### Complexity
* Constant.
*/
get firstNode(): LinkedList.INode<T> | null {
return this._first;
}
/**
* The last node in the list.
*
* This is `null` if the list is empty.
*
* #### Complexity
* Constant.
*/
get lastNode(): LinkedList.INode<T> | null {
return this._last;
}
/**
* Create an iterator over the values in the list.
*
* @returns A new iterator starting with the first value.
*
* #### Complexity
* Constant.
*/
*[Symbol.iterator](): IterableIterator<T> {
let node = this._first;
while (node) {
yield node.value;
node = node.next;
}
}
/**
* Create a reverse iterator over the values in the list.
*
* @returns A new iterator starting with the last value.
*
* #### Complexity
* Constant.
*/
*retro(): IterableIterator<T> {
let node = this._last;
while (node) {
yield node.value;
node = node.prev;
}
}
/**
* Create an iterator over the nodes in the list.
*
* @returns A new iterator starting with the first node.
*
* #### Complexity
* Constant.
*/
*nodes(): IterableIterator<LinkedList.INode<T>> {
let node = this._first;
while (node) {
yield node;
node = node.next;
}
}
/**
* Create a reverse iterator over the nodes in the list.
*
* @returns A new iterator starting with the last node.
*
* #### Complexity
* Constant.
*/
*retroNodes(): IterableIterator<LinkedList.INode<T>> {
let node = this._last;
while (node) {
yield node;
node = node.prev;
}
}
/**
* Assign new values to the list, replacing all current values.
*
* @param values - The values to assign to the list.
*
* #### Complexity
* Linear.
*/
assign(values: Iterable<T>): void {
this.clear();
for (const value of values) {
this.addLast(value);
}
}
/**
* Add a value to the end of the list.
*
* @param value - The value to add to the end of the list.
*
* #### Complexity
* Constant.
*
* #### Notes
* This is equivalent to `addLast`.
*/
push(value: T): void {
this.addLast(value);
}
/**
* Remove and return the value at the end of the list.
*
* @returns The removed value, or `undefined` if the list is empty.
*
* #### Complexity
* Constant.
*
* #### Notes
* This is equivalent to `removeLast`.
*/
pop(): T | undefined {
return this.removeLast();
}
/**
* Add a value to the beginning of the list.
*
* @param value - The value to add to the beginning of the list.
*
* #### Complexity
* Constant.
*
* #### Notes
* This is equivalent to `addFirst`.
*/
shift(value: T): void {
this.addFirst(value);
}
/**
* Remove and return the value at the beginning of the list.
*
* @returns The removed value, or `undefined` if the list is empty.
*
* #### Complexity
* Constant.
*
* #### Notes
* This is equivalent to `removeFirst`.
*/
unshift(): T | undefined {
return this.removeFirst();
}
/**
* Add a value to the beginning of the list.
*
* @param value - The value to add to the beginning of the list.
*
* @returns The list node which holds the value.
*
* #### Complexity
* Constant.
*/
addFirst(value: T): LinkedList.INode<T> {
const node = new Private.LinkedListNode<T>(this, value);
if (!this._first) {
this._first = node;
this._last = node;
} else {
node.next = this._first;
this._first.prev = node;
this._first = node;
}
this._size++;
return node;
}
/**
* Add a value to the end of the list.
*
* @param value - The value to add to the end of the list.
*
* @returns The list node which holds the value.
*
* #### Complexity
* Constant.
*/
addLast(value: T): LinkedList.INode<T> {
const node = new Private.LinkedListNode<T>(this, value);
if (!this._last) {
this._first = node;
this._last = node;
} else {
node.prev = this._last;
this._last.next = node;
this._last = node;
}
this._size++;
return node;
}
/**
* Insert a value before a specific node in the list.
*
* @param value - The value to insert before the reference node.
*
* @param ref - The reference node of interest. If this is `null`,
* the value will be added to the beginning of the list.
*
* @returns The list node which holds the value.
*
* #### Notes
* The reference node must be owned by the list.
*
* #### Complexity
* Constant.
*/
insertBefore(value: T, ref: LinkedList.INode<T> | null): LinkedList.INode<T> {
if (!ref || ref === this._first) {
return this.addFirst(value);
}
if (!(ref instanceof Private.LinkedListNode) || ref.list !== this) {
throw new Error('Reference node is not owned by the list.');
}
const node = new Private.LinkedListNode<T>(this, value);
const _ref = ref as Private.LinkedListNode<T>;
const prev = _ref.prev!;
node.next = _ref;
node.prev = prev;
_ref.prev = node;
prev.next = node;
this._size++;
return node;
}
/**
* Insert a value after a specific node in the list.
*
* @param value - The value to insert after the reference node.
*
* @param ref - The reference node of interest. If this is `null`,
* the value will be added to the end of the list.
*
* @returns The list node which holds the value.
*
* #### Notes
* The reference node must be owned by the list.
*
* #### Complexity
* Constant.
*/
insertAfter(value: T, ref: LinkedList.INode<T> | null): LinkedList.INode<T> {
if (!ref || ref === this._last) {
return this.addLast(value);
}
if (!(ref instanceof Private.LinkedListNode) || ref.list !== this) {
throw new Error('Reference node is not owned by the list.');
}
const node = new Private.LinkedListNode<T>(this, value);
const _ref = ref as Private.LinkedListNode<T>;
const next = _ref.next!;
node.next = next;
node.prev = _ref;
_ref.next = node;
next.prev = node;
this._size++;
return node;
}
/**
* Remove and return the value at the beginning of the list.
*
* @returns The removed value, or `undefined` if the list is empty.
*
* #### Complexity
* Constant.
*/
removeFirst(): T | undefined {
const node = this._first;
if (!node) {
return undefined;
}
if (node === this._last) {
this._first = null;
this._last = null;
} else {
this._first = node.next;
this._first!.prev = null;
}
node.list = null;
node.next = null;
node.prev = null;
this._size--;
return node.value;
}
/**
* Remove and return the value at the end of the list.
*
* @returns The removed value, or `undefined` if the list is empty.
*
* #### Complexity
* Constant.
*/
removeLast(): T | undefined {
const node = this._last;
if (!node) {
return undefined;
}
if (node === this._first) {
this._first = null;
this._last = null;
} else {
this._last = node.prev;
this._last!.next = null;
}
node.list = null;
node.next = null;
node.prev = null;
this._size--;
return node.value;
}
/**
* Remove a specific node from the list.
*
* @param node - The node to remove from the list.
*
* #### Complexity
* Constant.
*
* #### Notes
* The node must be owned by the list.
*/
removeNode(node: LinkedList.INode<T>): void {
if (!(node instanceof Private.LinkedListNode) || node.list !== this) {
throw new Error('Node is not owned by the list.');
}
const _node = node as Private.LinkedListNode<T>;
if (_node === this._first && _node === this._last) {
this._first = null;
this._last = null;
} else if (_node === this._first) {
this._first = _node.next;
this._first!.prev = null;
} else if (_node === this._last) {
this._last = _node.prev;
this._last!.next = null;
} else {
_node.next!.prev = _node.prev;
_node.prev!.next = _node.next;
}
_node.list = null;
_node.next = null;
_node.prev = null;
this._size--;
}
/**
* Remove all values from the list.
*
* #### Complexity
* Linear.
*/
clear(): void {
let node = this._first;
while (node) {
const { next } = node;
node.list = null;
node.prev = null;
node.next = null;
node = next;
}
this._first = null;
this._last = null;
this._size = 0;
}
private _first: Private.LinkedListNode<T> | null = null;
private _last: Private.LinkedListNode<T> | null = null;
private _size = 0;
}
/**
* The namespace for the `LinkedList` class statics.
*/
export namespace LinkedList {
/**
* An object which represents a node in a linked list.
*
* #### Notes
* User code will not create linked list nodes directly. Nodes
* are created automatically when values are added to a list.
*/
export interface INode<T> {
/**
* The linked list which created and owns the node.
*
* This will be `null` when the node is removed from the list.
*/
readonly list: LinkedList<T> | null;
/**
* The next node in the list.
*
* This will be `null` when the node is the last node in the list
* or when the node is removed from the list.
*/
readonly next: INode<T> | null;
/**
* The previous node in the list.
*
* This will be `null` when the node is the first node in the list
* or when the node is removed from the list.
*/
readonly prev: INode<T> | null;
/**
* The user value stored in the node.
*/
readonly value: T;
}
/**
* Create a linked list from an iterable of values.
*
* @param values - The iterable object of interest.
*
* @returns A new linked list initialized with the given values.
*
* #### Complexity
* Linear.
*/
export function from<T>(values: Iterable<T>): LinkedList<T> {
const list = new LinkedList<T>();
list.assign(values);
return list;
}
}
/**
* The namespace for the module implementation details.
*/
namespace Private {
/**
* The internal linked list node implementation.
*/
export class LinkedListNode<T> {
/**
* The linked list which created and owns the node.
*/
list: LinkedList<T> | null = null;
/**
* The next node in the list.
*/
next: LinkedListNode<T> | null = null;
/**
* The previous node in the list.
*/
prev: LinkedListNode<T> | null = null;
/**
* The user value stored in the node.
*/
readonly value: T;
/**
* Construct a new linked list node.
*
* @param list - The list which owns the node.
*
* @param value - The value for the link.
*/
constructor(list: LinkedList<T>, value: T) {
this.list = list;
this.value = value;
}
}
}

View File

@@ -0,0 +1,29 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// Copyright (c) Jupyter Development Team.
// Distributed under the terms of the Modified BSD License.
/*-----------------------------------------------------------------------------
| Copyright (c) 2014-2017, PhosphorJS Contributors
|
| Distributed under the terms of the BSD 3-Clause License.
|
| The full license is in the file LICENSE, distributed with this software.
|----------------------------------------------------------------------------*/
export * from './json';
export * from './mime';
export * from './promise';
export * from './token';

View File

@@ -0,0 +1,24 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/*
* Copyright (c) Jupyter Development Team.
* Distributed under the terms of the Modified BSD License.
*/
export * from './index.common';
export * from './random.node';
export * from './uuid.node';

View File

@@ -0,0 +1,32 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// Copyright (c) Jupyter Development Team.
// Distributed under the terms of the Modified BSD License.
/*-----------------------------------------------------------------------------
| Copyright (c) 2014-2017, PhosphorJS Contributors
|
| Distributed under the terms of the BSD 3-Clause License.
|
| The full license is in the file LICENSE, distributed with this software.
|----------------------------------------------------------------------------*/
/**
* @packageDocumentation
* @module coreutils
*/
export * from './index.common';
export * from './random.browser';
export * from './uuid.browser';

View File

@@ -0,0 +1,365 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// Copyright (c) Jupyter Development Team.
// Distributed under the terms of the Modified BSD License.
/*-----------------------------------------------------------------------------
| Copyright (c) 2014-2017, PhosphorJS Contributors
|
| Distributed under the terms of the BSD 3-Clause License.
|
| The full license is in the file LICENSE, distributed with this software.
|----------------------------------------------------------------------------*/
/**
* A type alias for a JSON primitive.
*/
export type JSONPrimitive = boolean | number | string | null;
/**
* A type alias for a JSON value.
*/
export type JSONValue = JSONPrimitive | JSONObject | JSONArray;
/**
* A type definition for a JSON object.
*/
export interface JSONObject {
[key: string]: JSONValue;
}
/**
* A type definition for a JSON array.
*/
export interface JSONArray extends Array<JSONValue> {}
/**
* A type definition for a readonly JSON object.
*/
export interface ReadonlyJSONObject {
readonly [key: string]: ReadonlyJSONValue;
}
/**
* A type definition for a readonly JSON array.
*/
export interface ReadonlyJSONArray extends ReadonlyArray<ReadonlyJSONValue> {}
/**
* A type alias for a readonly JSON value.
*/
export type ReadonlyJSONValue =
| JSONPrimitive
| ReadonlyJSONObject
| ReadonlyJSONArray;
/**
* A type alias for a partial JSON value.
*
* Note: Partial here means that JSON object attributes can be `undefined`.
*/
export type PartialJSONValue =
| JSONPrimitive
| PartialJSONObject
| PartialJSONArray;
/**
* A type definition for a partial JSON object.
*
* Note: Partial here means that the JSON object attributes can be `undefined`.
*/
export interface PartialJSONObject {
[key: string]: PartialJSONValue | undefined;
}
/**
* A type definition for a partial JSON array.
*
* Note: Partial here means that JSON object attributes can be `undefined`.
*/
export interface PartialJSONArray extends Array<PartialJSONValue> {}
/**
* A type definition for a readonly partial JSON object.
*
* Note: Partial here means that JSON object attributes can be `undefined`.
*/
export interface ReadonlyPartialJSONObject {
readonly [key: string]: ReadonlyPartialJSONValue | undefined;
}
/**
* A type definition for a readonly partial JSON array.
*
* Note: Partial here means that JSON object attributes can be `undefined`.
*/
export interface ReadonlyPartialJSONArray
extends ReadonlyArray<ReadonlyPartialJSONValue> {}
/**
* A type alias for a readonly partial JSON value.
*
* Note: Partial here means that JSON object attributes can be `undefined`.
*/
export type ReadonlyPartialJSONValue =
| JSONPrimitive
| ReadonlyPartialJSONObject
| ReadonlyPartialJSONArray;
/**
* The namespace for JSON-specific functions.
*/
export namespace JSONExt {
/**
* A shared frozen empty JSONObject
*/
export const emptyObject = Object.freeze({}) as ReadonlyJSONObject;
/**
* A shared frozen empty JSONArray
*/
export const emptyArray = Object.freeze([]) as ReadonlyJSONArray;
/**
* Test whether a JSON value is a primitive.
*
* @param value - The JSON value of interest.
*
* @returns `true` if the value is a primitive,`false` otherwise.
*/
export function isPrimitive(
value: ReadonlyPartialJSONValue,
): value is JSONPrimitive {
return (
value === null ||
typeof value === 'boolean' ||
typeof value === 'number' ||
typeof value === 'string'
);
}
/**
* Test whether a JSON value is an array.
*
* @param value - The JSON value of interest.
*
* @returns `true` if the value is a an array, `false` otherwise.
*/
export function isArray(value: JSONValue): value is JSONArray;
export function isArray(value: ReadonlyJSONValue): value is ReadonlyJSONArray;
export function isArray(value: PartialJSONValue): value is PartialJSONArray;
export function isArray(
value: ReadonlyPartialJSONValue,
): value is ReadonlyPartialJSONArray;
export function isArray(value: ReadonlyPartialJSONValue): boolean {
return Array.isArray(value);
}
/**
* Test whether a JSON value is an object.
*
* @param value - The JSON value of interest.
*
* @returns `true` if the value is a an object, `false` otherwise.
*/
export function isObject(value: JSONValue): value is JSONObject;
export function isObject(
value: ReadonlyJSONValue,
): value is ReadonlyJSONObject;
export function isObject(value: PartialJSONValue): value is PartialJSONObject;
export function isObject(
value: ReadonlyPartialJSONValue,
): value is ReadonlyPartialJSONObject;
export function isObject(value: ReadonlyPartialJSONValue): boolean {
return !isPrimitive(value) && !isArray(value);
}
/**
* Compare two JSON values for deep equality.
*
* @param first - The first JSON value of interest.
*
* @param second - The second JSON value of interest.
*
* @returns `true` if the values are equivalent, `false` otherwise.
*/
export function deepEqual(
first: ReadonlyPartialJSONValue,
second: ReadonlyPartialJSONValue,
): boolean {
// Check referential and primitive equality first.
if (first === second) {
return true;
}
// If one is a primitive, the `===` check ruled out the other.
if (isPrimitive(first) || isPrimitive(second)) {
return false;
}
// Test whether they are arrays.
const a1 = isArray(first);
const a2 = isArray(second);
// Bail if the types are different.
if (a1 !== a2) {
return false;
}
// If they are both arrays, compare them.
if (a1 && a2) {
return deepArrayEqual(
first as ReadonlyPartialJSONArray,
second as ReadonlyPartialJSONArray,
);
}
// At this point, they must both be objects.
return deepObjectEqual(
first as ReadonlyPartialJSONObject,
second as ReadonlyPartialJSONObject,
);
}
/**
* Create a deep copy of a JSON value.
*
* @param value - The JSON value to copy.
*
* @returns A deep copy of the given JSON value.
*/
export function deepCopy<T extends ReadonlyPartialJSONValue>(value: T): T {
// Do nothing for primitive values.
if (isPrimitive(value)) {
return value;
}
// Deep copy an array.
if (isArray(value)) {
return deepArrayCopy(value);
}
// Deep copy an object.
return deepObjectCopy(value);
}
/**
* Compare two JSON arrays for deep equality.
*/
function deepArrayEqual(
first: ReadonlyPartialJSONArray,
second: ReadonlyPartialJSONArray,
): boolean {
// Check referential equality first.
if (first === second) {
return true;
}
// Test the arrays for equal length.
if (first.length !== second.length) {
return false;
}
// Compare the values for equality.
for (let i = 0, n = first.length; i < n; ++i) {
if (!deepEqual(first[i], second[i])) {
return false;
}
}
// At this point, the arrays are equal.
return true;
}
/**
* Compare two JSON objects for deep equality.
*/
function deepObjectEqual(
first: ReadonlyPartialJSONObject,
second: ReadonlyPartialJSONObject,
): boolean {
// Check referential equality first.
if (first === second) {
return true;
}
// Check for the first object's keys in the second object.
for (const key in first) {
if (first[key] !== undefined && !(key in second)) {
return false;
}
}
// Check for the second object's keys in the first object.
for (const key in second) {
if (second[key] !== undefined && !(key in first)) {
return false;
}
}
// Compare the values for equality.
for (const key in first) {
// Get the values.
const firstValue = first[key];
const secondValue = second[key];
// If both are undefined, ignore the key.
if (firstValue === undefined && secondValue === undefined) {
continue;
}
// If only one value is undefined, the objects are not equal.
if (firstValue === undefined || secondValue === undefined) {
return false;
}
// Compare the values.
if (!deepEqual(firstValue, secondValue)) {
return false;
}
}
// At this point, the objects are equal.
return true;
}
/**
* Create a deep copy of a JSON array.
*/
function deepArrayCopy(value: any): any {
const result = new Array<any>(value.length);
for (let i = 0, n = value.length; i < n; ++i) {
result[i] = deepCopy(value[i]);
}
return result;
}
/**
* Create a deep copy of a JSON object.
*/
function deepObjectCopy(value: any): any {
const result: any = {};
for (const key in value) {
// Ignore undefined values.
const subvalue = value[key];
if (subvalue === undefined) {
continue;
}
result[key] = deepCopy(subvalue);
}
return result;
}
}

View File

@@ -0,0 +1,114 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// Copyright (c) Jupyter Development Team.
// Distributed under the terms of the Modified BSD License.
/*-----------------------------------------------------------------------------
| Copyright (c) 2014-2017, PhosphorJS Contributors
|
| Distributed under the terms of the BSD 3-Clause License.
|
| The full license is in the file LICENSE, distributed with this software.
|----------------------------------------------------------------------------*/
/**
* An object which stores MIME data for general application use.
*
* #### Notes
* This class does not attempt to enforce "correctness" of MIME types
* and their associated data. Since this class is designed to transfer
* arbitrary data and objects within the same application, it assumes
* that the user provides correct and accurate data.
*/
export class MimeData {
/**
* Get an array of the MIME types contained within the dataset.
*
* @returns A new array of the MIME types, in order of insertion.
*/
types(): string[] {
return this._types.slice();
}
/**
* Test whether the dataset has an entry for the given type.
*
* @param mime - The MIME type of interest.
*
* @returns `true` if the dataset contains a value for the given
* MIME type, `false` otherwise.
*/
hasData(mime: string): boolean {
return this._types.indexOf(mime) !== -1;
}
/**
* Get the data value for the given MIME type.
*
* @param mime - The MIME type of interest.
*
* @returns The value for the given MIME type, or `undefined` if
* the dataset does not contain a value for the type.
*/
getData(mime: string): any | undefined {
const i = this._types.indexOf(mime);
return i !== -1 ? this._values[i] : undefined;
}
/**
* Set the data value for the given MIME type.
*
* @param mime - The MIME type of interest.
*
* @param data - The data value for the given MIME type.
*
* #### Notes
* This will overwrite any previous entry for the MIME type.
*/
setData(mime: string, data: unknown): void {
this.clearData(mime);
this._types.push(mime);
this._values.push(data);
}
/**
* Remove the data entry for the given MIME type.
*
* @param mime - The MIME type of interest.
*
* #### Notes
* This is a no-op if there is no entry for the given MIME type.
*/
clearData(mime: string): void {
const i = this._types.indexOf(mime);
if (i !== -1) {
this._types.splice(i, 1);
this._values.splice(i, 1);
}
}
/**
* Remove all data entries from the dataset.
*/
clear(): void {
this._types.length = 0;
this._values.length = 0;
}
private _types: string[] = [];
private _values: any[] = [];
}

View File

@@ -0,0 +1,73 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// Copyright (c) Jupyter Development Team.
// Distributed under the terms of the Modified BSD License.
/*-----------------------------------------------------------------------------
| Copyright (c) 2014-2017, PhosphorJS Contributors
|
| Distributed under the terms of the BSD 3-Clause License.
|
| The full license is in the file LICENSE, distributed with this software.
|----------------------------------------------------------------------------*/
/**
* A class which wraps a promise into a delegate object.
*
* #### Notes
* This class is useful when the logic to resolve or reject a promise
* cannot be defined at the point where the promise is created.
*/
export class PromiseDelegate<T> {
/**
* Construct a new promise delegate.
*/
constructor() {
this.promise = new Promise<T>((resolve, reject) => {
this._resolve = resolve;
this._reject = reject;
});
}
/**
* The promise wrapped by the delegate.
*/
readonly promise: Promise<T>;
/**
* Resolve the wrapped promise with the given value.
*
* @param value - The value to use for resolving the promise.
*/
resolve(value: T | PromiseLike<T>): void {
const resolve = this._resolve;
resolve(value);
}
/**
* Reject the wrapped promise with the given value.
*
* @reason - The reason for rejecting the promise.
*/
reject(reason: unknown): void {
const reject = this._reject;
reject(reason);
}
private _resolve: (value: T | PromiseLike<T>) => void;
private _reject: (reason: any) => void;
}

View File

@@ -0,0 +1,70 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// Copyright (c) Jupyter Development Team.
// Distributed under the terms of the Modified BSD License.
/*-----------------------------------------------------------------------------
| Copyright (c) 2014-2017, PhosphorJS Contributors
|
| Distributed under the terms of the BSD 3-Clause License.
|
| The full license is in the file LICENSE, distributed with this software.
|----------------------------------------------------------------------------*/
import { fallbackRandomValues } from './random';
// Declare ambient variables for `window` and `require` to avoid a
// hard dependency on both. This package must run on node.
declare let window: any;
/**
* The namespace for random number related functionality.
*/
export namespace Random {
/**
* A function which generates random bytes.
*
* @param buffer - The `Uint8Array` to fill with random bytes.
*
* #### Notes
* A cryptographically strong random number generator will be used if
* available. Otherwise, `Math.random` will be used as a fallback for
* randomness.
*
* The following RNGs are supported, listed in order of precedence:
* - `window.crypto.getRandomValues`
* - `window.msCrypto.getRandomValues`
* - `require('crypto').randomFillSync
* - `require('crypto').randomBytes
* - `Math.random`
*/
export const getRandomValues = (() => {
// Look up the crypto module if available.
const crypto: any =
(typeof window !== 'undefined' && (window.crypto || window.msCrypto)) ||
null;
// Modern browsers and IE 11
if (crypto && typeof crypto.getRandomValues === 'function') {
return function getRandomValues(buffer: Uint8Array): void {
return crypto.getRandomValues(buffer);
};
}
// Fallback
return fallbackRandomValues;
})();
}

View File

@@ -0,0 +1,79 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// Copyright (c) Jupyter Development Team.
// Distributed under the terms of the Modified BSD License.
/*-----------------------------------------------------------------------------
| Copyright (c) 2014-2017, PhosphorJS Contributors
|
| Distributed under the terms of the BSD 3-Clause License.
|
| The full license is in the file LICENSE, distributed with this software.
|----------------------------------------------------------------------------*/
import { fallbackRandomValues } from './random';
// Declare ambient variables for `window` and `require` to avoid a
// hard dependency on both. This package must run on node.
declare let require: any;
/**
* The namespace for random number related functionality.
*/
export namespace Random {
/**
* A function which generates random bytes.
*
* @param buffer - The `Uint8Array` to fill with random bytes.
*
* #### Notes
* A cryptographically strong random number generator will be used if
* available. Otherwise, `Math.random` will be used as a fallback for
* randomness.
*
* The following RNGs are supported, listed in order of precedence:
* - `window.crypto.getRandomValues`
* - `window.msCrypto.getRandomValues`
* - `require('crypto').randomFillSync
* - `require('crypto').randomBytes
* - `Math.random`
*/
export const getRandomValues = (() => {
// Look up the crypto module if available.
const crypto: any =
(typeof require !== 'undefined' && require('crypto')) || null;
// Node 7+
if (crypto && typeof crypto.randomFillSync === 'function') {
return function getRandomValues(buffer: Uint8Array): void {
return crypto.randomFillSync(buffer);
};
}
// Node 0.10+
if (crypto && typeof crypto.randomBytes === 'function') {
return function getRandomValues(buffer: Uint8Array): void {
const bytes = crypto.randomBytes(buffer.length);
for (let i = 0, n = bytes.length; i < n; ++i) {
buffer[i] = bytes[i];
}
};
}
// Fallback
return fallbackRandomValues;
})();
}

View File

@@ -0,0 +1,37 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// Copyright (c) Jupyter Development Team.
// Distributed under the terms of the Modified BSD License.
/*-----------------------------------------------------------------------------
| Copyright (c) 2014-2017, PhosphorJS Contributors
|
| Distributed under the terms of the BSD 3-Clause License.
|
| The full license is in the file LICENSE, distributed with this software.
|----------------------------------------------------------------------------*/
// Fallback
export function fallbackRandomValues(buffer: Uint8Array): void {
let value = 0;
for (let i = 0, n = buffer.length; i < n; ++i) {
if (i % 4 === 0) {
value = (Math.random() * 0xffffffff) >>> 0;
}
buffer[i] = value & 0xff;
value >>>= 8;
}
}

View File

@@ -0,0 +1,61 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// Copyright (c) Jupyter Development Team.
// Distributed under the terms of the Modified BSD License.
/*-----------------------------------------------------------------------------
| Copyright (c) 2014-2017, PhosphorJS Contributors
|
| Distributed under the terms of the BSD 3-Clause License.
|
| The full license is in the file LICENSE, distributed with this software.
|----------------------------------------------------------------------------*/
/**
* A runtime object which captures compile-time type information.
*
* #### Notes
* A token captures the compile-time type of an interface or class in
* an object which can be used at runtime in a type-safe fashion.
*/
export class Token<T> {
/**
* Construct a new token.
*
* @param name - A human readable name for the token.
* @param description - Token purpose description for documentation.
*/
constructor(name: string, description?: string) {
this.name = name;
this.description = description ?? '';
this._tokenStructuralPropertyT = null!;
}
/**
* Token purpose description.
*/
readonly description?: string; // FIXME remove `?` for the next major version
/**
* The human readable name for the token.
*
* #### Notes
* This can be useful for debugging and logging.
*/
readonly name: string;
private _tokenStructuralPropertyT: T;
}

View File

@@ -0,0 +1,46 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// Copyright (c) Jupyter Development Team.
// Distributed under the terms of the Modified BSD License.
/*-----------------------------------------------------------------------------
| Copyright (c) 2014-2017, PhosphorJS Contributors
|
| Distributed under the terms of the BSD 3-Clause License.
|
| The full license is in the file LICENSE, distributed with this software.
|----------------------------------------------------------------------------*/
import { uuid4Factory } from './uuid';
import { Random } from './random.browser';
/**
* The namespace for UUID related functionality.
*/
export namespace UUID {
/**
* A function which generates UUID v4 identifiers.
*
* @returns A new UUID v4 string.
*
* #### Notes
* This implementation complies with RFC 4122.
*
* This uses `Random.getRandomValues()` for random bytes, which in
* turn will use the underlying `crypto` module of the platform if
* it is available. The fallback for randomness is `Math.random`.
*/
export const uuid4 = uuid4Factory(Random.getRandomValues);
}

View File

@@ -0,0 +1,46 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// Copyright (c) Jupyter Development Team.
// Distributed under the terms of the Modified BSD License.
/*-----------------------------------------------------------------------------
| Copyright (c) 2014-2017, PhosphorJS Contributors
|
| Distributed under the terms of the BSD 3-Clause License.
|
| The full license is in the file LICENSE, distributed with this software.
|----------------------------------------------------------------------------*/
import { uuid4Factory } from './uuid';
import { Random } from './random.node';
/**
* The namespace for UUID related functionality.
*/
export namespace UUID {
/**
* A function which generates UUID v4 identifiers.
*
* @returns A new UUID v4 string.
*
* #### Notes
* This implementation complies with RFC 4122.
*
* This uses `Random.getRandomValues()` for random bytes, which in
* turn will use the underlying `crypto` module of the platform if
* it is available. The fallback for randomness is `Math.random`.
*/
export const uuid4 = uuid4Factory(Random.getRandomValues);
}

View File

@@ -0,0 +1,77 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// Copyright (c) Jupyter Development Team.
// Distributed under the terms of the Modified BSD License.
/*-----------------------------------------------------------------------------
| Copyright (c) 2014-2017, PhosphorJS Contributors
|
| Distributed under the terms of the BSD 3-Clause License.
|
| The full license is in the file LICENSE, distributed with this software.
|----------------------------------------------------------------------------*/
/**
* A function which creates a function that generates UUID v4 identifiers.
*
* @returns A new function that creates a UUID v4 string.
*
* #### Notes
* This implementation complies with RFC 4122.
*
* This uses `Random.getRandomValues()` for random bytes, which in
* turn will use the underlying `crypto` module of the platform if
* it is available. The fallback for randomness is `Math.random`.
*/
export function uuid4Factory(
getRandomValues: (bytes: Uint8Array) => void,
): () => string {
// Create a 16 byte array to hold the random values.
const bytes = new Uint8Array(16);
// Create a look up table from bytes to hex strings.
const lut = new Array<string>(256);
// Pad the single character hex digits with a leading zero.
for (let i = 0; i < 16; ++i) {
lut[i] = `0${i.toString(16)}`;
}
// Populate the rest of the hex digits.
for (let i = 16; i < 256; ++i) {
lut[i] = i.toString(16);
}
// Return a function which generates the UUID.
return function uuid4(): string {
// Get a new batch of random values.
getRandomValues(bytes);
// Set the UUID version number to 4.
bytes[6] = 0x40 | (bytes[6] & 0x0f);
// Set the clock sequence bit to the RFC spec.
bytes[8] = 0x80 | (bytes[8] & 0x3f);
// Assemble the UUID string.
return `${lut[bytes[0]] + lut[bytes[1]] + lut[bytes[2]] + lut[bytes[3]]}-${
lut[bytes[4]]
}${lut[bytes[5]]}-${lut[bytes[6]]}${lut[bytes[7]]}-${lut[bytes[8]]}${
lut[bytes[9]]
}-${lut[bytes[10]]}${lut[bytes[11]]}${lut[bytes[12]]}${lut[bytes[13]]}${
lut[bytes[14]]
}${lut[bytes[15]]}`;
};
}

View File

@@ -0,0 +1,277 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// Copyright (c) Jupyter Development Team.
// Distributed under the terms of the Modified BSD License.
/*-----------------------------------------------------------------------------
| Copyright (c) 2014-2017, PhosphorJS Contributors
|
| Distributed under the terms of the BSD 3-Clause License.
|
| The full license is in the file LICENSE, distributed with this software.
|----------------------------------------------------------------------------*/
/**
* @packageDocumentation
* @module disposable
*/
import { type ISignal, Signal } from '../signaling';
/**
* An object which implements the disposable pattern.
*/
export interface IDisposable {
/**
* Test whether the object has been disposed.
*
* #### Notes
* This property is always safe to access.
*/
readonly isDisposed: boolean;
/**
* Dispose of the resources held by the object.
*
* #### Notes
* If the object's `dispose` method is called more than once, all
* calls made after the first will be a no-op.
*
* #### Undefined Behavior
* It is undefined behavior to use any functionality of the object
* after it has been disposed unless otherwise explicitly noted.
*/
dispose(): void;
}
/**
* A disposable object with an observable `disposed` signal.
*/
export interface IObservableDisposable extends IDisposable {
/**
* A signal emitted when the object is disposed.
*/
readonly disposed: ISignal<this, void>;
}
/**
* A disposable object which delegates to a callback function.
*/
export class DisposableDelegate implements IDisposable {
/**
* Construct a new disposable delegate.
*
* @param fn - The callback function to invoke on dispose.
*/
constructor(fn: () => void) {
this._fn = fn;
}
/**
* Test whether the delegate has been disposed.
*/
get isDisposed(): boolean {
return !this._fn;
}
/**
* Dispose of the delegate and invoke the callback function.
*/
dispose(): void {
if (!this._fn) {
return;
}
const fn = this._fn;
this._fn = null;
fn();
}
private _fn: (() => void) | null;
}
/**
* An observable disposable object which delegates to a callback function.
*/
export class ObservableDisposableDelegate
extends DisposableDelegate
implements IObservableDisposable
{
/**
* A signal emitted when the delegate is disposed.
*/
get disposed(): ISignal<this, void> {
return this._disposed;
}
/**
* Dispose of the delegate and invoke the callback function.
*/
dispose(): void {
if (this.isDisposed) {
return;
}
super.dispose();
this._disposed.emit(undefined);
Signal.clearData(this);
}
private _disposed = new Signal<this, void>(this);
}
/**
* An object which manages a collection of disposable items.
*/
export class DisposableSet implements IDisposable {
/**
* Test whether the set has been disposed.
*/
get isDisposed(): boolean {
return this._isDisposed;
}
/**
* Dispose of the set and the items it contains.
*
* #### Notes
* Items are disposed in the order they are added to the set.
*/
dispose(): void {
if (this._isDisposed) {
return;
}
this._isDisposed = true;
this._items.forEach(item => {
item.dispose();
});
this._items.clear();
}
/**
* Test whether the set contains a specific item.
*
* @param item - The item of interest.
*
* @returns `true` if the set contains the item, `false` otherwise.
*/
contains(item: IDisposable): boolean {
return this._items.has(item);
}
/**
* Add a disposable item to the set.
*
* @param item - The item to add to the set.
*
* #### Notes
* If the item is already contained in the set, this is a no-op.
*/
add(item: IDisposable): void {
this._items.add(item);
}
/**
* Remove a disposable item from the set.
*
* @param item - The item to remove from the set.
*
* #### Notes
* If the item is not contained in the set, this is a no-op.
*/
remove(item: IDisposable): void {
this._items.delete(item);
}
/**
* Remove all items from the set.
*/
clear(): void {
this._items.clear();
}
private _isDisposed = false;
private _items = new Set<IDisposable>();
}
/**
* The namespace for the `DisposableSet` class statics.
*/
export namespace DisposableSet {
/**
* Create a disposable set from an iterable of items.
*
* @param items - The iterable object of interest.
*
* @returns A new disposable initialized with the given items.
*/
export function from(items: Iterable<IDisposable>): DisposableSet {
const set = new DisposableSet();
for (const item of items) {
set.add(item);
}
return set;
}
}
/**
* An observable object which manages a collection of disposable items.
*/
export class ObservableDisposableSet
extends DisposableSet
implements IObservableDisposable
{
/**
* A signal emitted when the set is disposed.
*/
get disposed(): ISignal<this, void> {
return this._disposed;
}
/**
* Dispose of the set and the items it contains.
*
* #### Notes
* Items are disposed in the order they are added to the set.
*/
dispose(): void {
if (this.isDisposed) {
return;
}
super.dispose();
this._disposed.emit(undefined);
Signal.clearData(this);
}
private _disposed = new Signal<this, void>(this);
}
/**
* The namespace for the `ObservableDisposableSet` class statics.
*/
export namespace ObservableDisposableSet {
/**
* Create an observable disposable set from an iterable of items.
*
* @param items - The iterable object of interest.
*
* @returns A new disposable initialized with the given items.
*/
export function from(items: Iterable<IDisposable>): ObservableDisposableSet {
const set = new ObservableDisposableSet();
for (const item of items) {
set.add(item);
}
return set;
}
}

View File

@@ -0,0 +1,59 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// Copyright (c) Jupyter Development Team.
// Distributed under the terms of the Modified BSD License.
/*-----------------------------------------------------------------------------
| Copyright (c) 2014-2019, PhosphorJS Contributors
|
| Distributed under the terms of the BSD 3-Clause License.
|
| The full license is in the file LICENSE, distributed with this software.
|----------------------------------------------------------------------------*/
/**
* The namespace for clipboard related functionality.
*/
export namespace ClipboardExt {
/**
* Copy text to the system clipboard.
*
* @param text - The text to copy to the clipboard.
*/
export function copyText(text: string): void {
// Fetch the document body.
const { body } = document;
// Set up the clipboard event listener.
const handler = (event: ClipboardEvent) => {
// Stop the event propagation.
event.preventDefault();
event.stopPropagation();
// Set the clipboard data.
event.clipboardData!.setData('text', text);
// Remove the event listener.
body.removeEventListener('copy', handler, true);
};
// Add the event listener.
body.addEventListener('copy', handler, true);
// Trigger the event.
document.execCommand('copy');
}
}

View File

@@ -0,0 +1,229 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// Copyright (c) Jupyter Development Team.
// Distributed under the terms of the Modified BSD License.
/*-----------------------------------------------------------------------------
| Copyright (c) 2014-2017, PhosphorJS Contributors
|
| Distributed under the terms of the BSD 3-Clause License.
|
| The full license is in the file LICENSE, distributed with this software.
|----------------------------------------------------------------------------*/
/**
* The namespace for element related utilities.
*/
export namespace ElementExt {
/**
* An object which holds the border and padding data for an element.
*/
export interface IBoxSizing {
/**
* The top border width, in pixels.
*/
borderTop: number;
/**
* The left border width, in pixels.
*/
borderLeft: number;
/**
* The right border width, in pixels.
*/
borderRight: number;
/**
* The bottom border width, in pixels.
*/
borderBottom: number;
/**
* The top padding width, in pixels.
*/
paddingTop: number;
/**
* The left padding width, in pixels.
*/
paddingLeft: number;
/**
* The right padding width, in pixels.
*/
paddingRight: number;
/**
* The bottom padding width, in pixels.
*/
paddingBottom: number;
/**
* The sum of horizontal border and padding.
*/
horizontalSum: number;
/**
* The sum of vertical border and padding.
*/
verticalSum: number;
}
/**
* Compute the box sizing for an element.
*
* @param element - The element of interest.
*
* @returns The box sizing data for the specified element.
*/
export function boxSizing(element: Element): IBoxSizing {
const style = window.getComputedStyle(element);
const bt = parseFloat(style.borderTopWidth!) || 0;
const bl = parseFloat(style.borderLeftWidth!) || 0;
const br = parseFloat(style.borderRightWidth!) || 0;
const bb = parseFloat(style.borderBottomWidth!) || 0;
const pt = parseFloat(style.paddingTop!) || 0;
const pl = parseFloat(style.paddingLeft!) || 0;
const pr = parseFloat(style.paddingRight!) || 0;
const pb = parseFloat(style.paddingBottom!) || 0;
const hs = bl + pl + pr + br;
const vs = bt + pt + pb + bb;
return {
borderTop: bt,
borderLeft: bl,
borderRight: br,
borderBottom: bb,
paddingTop: pt,
paddingLeft: pl,
paddingRight: pr,
paddingBottom: pb,
horizontalSum: hs,
verticalSum: vs,
};
}
/**
* An object which holds the min and max size data for an element.
*/
export interface ISizeLimits {
/**
* The minimum width, in pixels.
*/
minWidth: number;
/**
* The minimum height, in pixels.
*/
minHeight: number;
/**
* The maximum width, in pixels.
*/
maxWidth: number;
/**
* The maximum height, in pixels.
*/
maxHeight: number;
}
/**
* Compute the size limits for an element.
*
* @param element - The element of interest.
*
* @returns The size limit data for the specified element.
*/
export function sizeLimits(element: Element): ISizeLimits {
const style = window.getComputedStyle(element);
const minWidth = parseFloat(style.minWidth!) || 0;
const minHeight = parseFloat(style.minHeight!) || 0;
let maxWidth = parseFloat(style.maxWidth!) || Infinity;
let maxHeight = parseFloat(style.maxHeight!) || Infinity;
maxWidth = Math.max(minWidth, maxWidth);
maxHeight = Math.max(minHeight, maxHeight);
return { minWidth, minHeight, maxWidth, maxHeight };
}
/**
* Test whether a client position lies within an element.
*
* @param element - The DOM element of interest.
*
* @param clientX - The client X coordinate of interest.
*
* @param clientY - The client Y coordinate of interest.
*
* @returns Whether the point is within the given element.
*/
export function hitTest(
element: Element,
clientX: number,
clientY: number,
): boolean {
const rect = element.getBoundingClientRect();
return (
clientX >= rect.left &&
clientX < rect.right &&
clientY >= rect.top &&
clientY < rect.bottom
);
}
/**
* Vertically scroll an element into view if needed.
*
* @param area - The scroll area element.
*
* @param element - The element of interest.
*
* #### Notes
* This follows the "nearest" behavior of the native `scrollIntoView`
* method, which is not supported by all browsers.
* https://drafts.csswg.org/cssom-view/#element-scrolling-members
*
* If the element fully covers the visible area or is fully contained
* within the visible area, no scrolling will take place. Otherwise,
* the nearest edges of the area and element are aligned.
*/
export function scrollIntoViewIfNeeded(
area: Element,
element: Element,
): void {
const ar = area.getBoundingClientRect();
const er = element.getBoundingClientRect();
if (er.top <= ar.top && er.bottom >= ar.bottom) {
return;
}
if (er.top < ar.top && er.height <= ar.height) {
area.scrollTop -= ar.top - er.top;
return;
}
if (er.bottom > ar.bottom && er.height >= ar.height) {
area.scrollTop -= ar.top - er.top;
return;
}
if (er.top < ar.top && er.height > ar.height) {
area.scrollTop -= ar.bottom - er.bottom;
return;
}
if (er.bottom > ar.bottom && er.height < ar.height) {
area.scrollTop -= ar.bottom - er.bottom;
return;
}
}
}

View File

@@ -0,0 +1,33 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// Copyright (c) Jupyter Development Team.
// Distributed under the terms of the Modified BSD License.
/*-----------------------------------------------------------------------------
| Copyright (c) 2014-2017, PhosphorJS Contributors
|
| Distributed under the terms of the BSD 3-Clause License.
|
| The full license is in the file LICENSE, distributed with this software.
|----------------------------------------------------------------------------*/
/**
* @packageDocumentation
* @module domutils
*/
export * from './clipboard';
export * from './element';
export * from './platform';
export * from './selector';

View File

@@ -0,0 +1,65 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// Copyright (c) Jupyter Development Team.
// Distributed under the terms of the Modified BSD License.
/*-----------------------------------------------------------------------------
| Copyright (c) 2014-2017, PhosphorJS Contributors
|
| Distributed under the terms of the BSD 3-Clause License.
|
| The full license is in the file LICENSE, distributed with this software.
|----------------------------------------------------------------------------*/
/**
* The namespace for platform related utilities.
*/
export namespace Platform {
/**
* A flag indicating whether the platform is Mac.
*/
export const IS_MAC = !!navigator.platform.match(/Mac/i);
/**
* A flag indicating whether the platform is Windows.
*/
export const IS_WIN = !!navigator.platform.match(/Win/i);
/**
* A flag indicating whether the browser is IE.
*/
export const IS_IE = /Trident/.test(navigator.userAgent);
/**
* A flag indicating whether the browser is Edge.
*/
export const IS_EDGE = /Edge/.test(navigator.userAgent);
/**
* Test whether the `accel` key is pressed.
*
* @param event - The keyboard or mouse event of interest.
*
* @returns Whether the `accel` key is pressed.
*
* #### Notes
* On Mac the `accel` key is the command key. On all other
* platforms the `accel` key is the control key.
*/
export function accelKey(event: KeyboardEvent | MouseEvent): boolean {
return IS_MAC ? event.metaKey : event.ctrlKey;
}
}

View File

@@ -0,0 +1,281 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// Copyright (c) Jupyter Development Team.
// Distributed under the terms of the Modified BSD License.
/*-----------------------------------------------------------------------------
| Copyright (c) 2014-2017, PhosphorJS Contributors
|
| Distributed under the terms of the BSD 3-Clause License.
|
| The full license is in the file LICENSE, distributed with this software.
|----------------------------------------------------------------------------*/
/**
* The namespace for selector related utilities.
*/
export namespace Selector {
/**
* Calculate the specificity of a single CSS selector.
*
* @param selector - The CSS selector of interest.
*
* @returns The specificity of the selector.
*
* #### Undefined Behavior
* The selector is invalid.
*
* #### Notes
* This is based on https://www.w3.org/TR/css3-selectors/#specificity
*
* A larger number represents a more specific selector.
*
* The smallest possible specificity is `0`.
*
* The result is represented as a hex number `0x<aa><bb><cc>` where
* each component is the count of the respective selector clause.
*
* If the selector contains commas, only the first clause is used.
*
* The computed result is cached, so subsequent calculations for the
* same selector are extremely fast.
*/
export function calculateSpecificity(selector: string): number {
if (selector in Private.specificityCache) {
return Private.specificityCache[selector];
}
const result = Private.calculateSingle(selector);
return (Private.specificityCache[selector] = result);
}
/**
* Test whether a selector is a valid CSS selector.
*
* @param selector - The CSS selector of interest.
*
* @returns `true` if the selector is valid, `false` otherwise.
*
* #### Notes
* The computed result is cached, so subsequent tests for the same
* selector are extremely fast.
*/
export function isValid(selector: string): boolean {
if (selector in Private.validityCache) {
return Private.validityCache[selector];
}
let result = true;
try {
Private.testElem.querySelector(selector);
} catch (err) {
result = false;
}
return (Private.validityCache[selector] = result);
}
/**
* Test whether an element matches a CSS selector.
*
* @param element - The element of interest.
*
* @param selector - The valid CSS selector of interest.
*
* @returns `true` if the element is a match, `false` otherwise.
*
* #### Notes
* This function uses the builtin browser capabilities when possible,
* falling back onto a document query otherwise.
*/
export function matches(element: Element, selector: string): boolean {
return Private.protoMatchFunc.call(element, selector);
}
}
/**
* The namespace for the module implementation details.
*/
namespace Private {
/**
* A type alias for an object hash.
*/
export interface StringMap<T> {
[key: string]: T;
}
/**
* A cache of computed selector specificity values.
*/
export const specificityCache: StringMap<number> = Object.create(null);
/**
* A cache of computed selector validity.
*/
export const validityCache: StringMap<boolean> = Object.create(null);
/**
* An empty element for testing selector validity.
*/
export const testElem = document.createElement('div');
/**
* A cross-browser CSS selector matching prototype function.
*/
export const protoMatchFunc = (() => {
const proto = Element.prototype as any;
return (
proto.matches ||
proto.matchesSelector ||
proto.mozMatchesSelector ||
proto.msMatchesSelector ||
proto.oMatchesSelector ||
proto.webkitMatchesSelector ||
function (selector: string) {
// @ts-expect-error
const elem = this as Element;
const matches = elem.ownerDocument
? elem.ownerDocument.querySelectorAll(selector)
: [];
return Array.prototype.indexOf.call(matches, elem) !== -1;
}
);
})();
/**
* Calculate the specificity of a single selector.
*
* The behavior is undefined if the selector is invalid.
*/
export function calculateSingle(selector: string): number {
// Ignore anything after the first comma.
selector = selector.split(',', 1)[0];
// Setup the aggregate counters.
let a = 0;
let b = 0;
let c = 0;
// Apply a regex to the front of the selector. If it succeeds, that
// portion of the selector is removed. Returns a success/fail flag.
function match(re: RegExp): boolean {
const match = selector.match(re);
if (match === null) {
return false;
}
selector = selector.slice(match[0].length);
return true;
}
// Replace the negation pseudo-class (which is ignored),
// but keep its inner content (which is not ignored).
selector = selector.replace(NEGATION_RE, ' $1 ');
// Continue matching until the selector is consumed.
while (selector.length > 0) {
// Match an ID selector.
if (match(ID_RE)) {
a++;
continue;
}
// Match a class selector.
if (match(CLASS_RE)) {
b++;
continue;
}
// Match an attribute selector.
if (match(ATTR_RE)) {
b++;
continue;
}
// Match a pseudo-element selector. This is done before matching
// a pseudo-class since this regex overlaps with that regex.
if (match(PSEUDO_ELEM_RE)) {
c++;
continue;
}
// Match a pseudo-class selector.
if (match(PSEDUO_CLASS_RE)) {
b++;
continue;
}
// Match a plain type selector.
if (match(TYPE_RE)) {
c++;
continue;
}
// Finally, match any ignored characters.
if (match(IGNORE_RE)) {
continue;
}
// At this point, the selector is assumed to be invalid.
return 0;
}
// Clamp each component to a reasonable base.
a = Math.min(a, 0xff);
b = Math.min(b, 0xff);
c = Math.min(c, 0xff);
// Combine the components into a single result.
return (a << 16) | (b << 8) | c;
}
/**
* A regex which matches an ID selector at string start.
*/
const ID_RE = /^#[^\s\+>~#\.\[:]+/;
/**
* A regex which matches a class selector at string start.
*/
const CLASS_RE = /^\.[^\s\+>~#\.\[:]+/;
/**
* A regex which matches an attribute selector at string start.
*/
const ATTR_RE = /^\[[^\]]+\]/;
/**
* A regex which matches a type selector at string start.
*/
const TYPE_RE = /^[^\s\+>~#\.\[:]+/;
/**
* A regex which matches a pseudo-element selector at string start.
*/
const PSEUDO_ELEM_RE =
/^(::[^\s\+>~#\.\[:]+|:first-line|:first-letter|:before|:after)/;
/**
* A regex which matches a pseudo-class selector at string start.
*/
const PSEDUO_CLASS_RE = /^:[^\s\+>~#\.\[:]+/;
/**
* A regex which matches ignored characters at string start.
*/
const IGNORE_RE = /^[\s\+>~\*]+/;
/**
* A regex which matches the negation pseudo-class globally.
*/
const NEGATION_RE = /:not\(([^\)]+)\)/g;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,659 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// Copyright (c) Jupyter Development Team.
// Distributed under the terms of the Modified BSD License.
/*-----------------------------------------------------------------------------
| Copyright (c) 2014-2017, PhosphorJS Contributors
|
| Distributed under the terms of the BSD 3-Clause License.
|
| The full license is in the file LICENSE, distributed with this software.
|----------------------------------------------------------------------------*/
/**
* @packageDocumentation
* @module messaging
*/
import { LinkedList } from '../collections';
import { ArrayExt, every, retro, some } from '../algorithm';
/**
* A message which can be delivered to a message handler.
*
* #### Notes
* This class may be subclassed to create complex message types.
*/
export class Message {
/**
* Construct a new message.
*
* @param type - The type of the message.
*/
constructor(type: string) {
this.type = type;
}
/**
* The type of the message.
*
* #### Notes
* The `type` of a message should be related directly to its actual
* runtime type. This means that `type` can and will be used to cast
* the message to the relevant derived `Message` subtype.
*/
readonly type: string;
/**
* Test whether the message is conflatable.
*
* #### Notes
* Message conflation is an advanced topic. Most message types will
* not make use of this feature.
*
* If a conflatable message is posted to a handler while another
* conflatable message of the same `type` has already been posted
* to the handler, the `conflate()` method of the existing message
* will be invoked. If that method returns `true`, the new message
* will not be enqueued. This allows messages to be compressed, so
* that only a single instance of the message type is processed per
* cycle, no matter how many times messages of that type are posted.
*
* Custom message types may reimplement this property.
*
* The default implementation is always `false`.
*/
get isConflatable(): boolean {
return false;
}
/**
* Conflate this message with another message of the same `type`.
*
* @param other - A conflatable message of the same `type`.
*
* @returns `true` if the message was successfully conflated, or
* `false` otherwise.
*
* #### Notes
* Message conflation is an advanced topic. Most message types will
* not make use of this feature.
*
* This method is called automatically by the message loop when the
* given message is posted to the handler paired with this message.
* This message will already be enqueued and conflatable, and the
* given message will have the same `type` and also be conflatable.
*
* This method should merge the state of the other message into this
* message as needed so that when this message is finally delivered
* to the handler, it receives the most up-to-date information.
*
* If this method returns `true`, it signals that the other message
* was successfully conflated and that message will not be enqueued.
*
* If this method returns `false`, the other message will be enqueued
* for normal delivery.
*
* Custom message types may reimplement this method.
*
* The default implementation always returns `false`.
*/
conflate(other: Message): boolean {
return false;
}
}
/**
* A convenience message class which conflates automatically.
*
* #### Notes
* Message conflation is an advanced topic. Most user code will not
* make use of this class.
*
* This message class is useful for creating message instances which
* should be conflated, but which have no state other than `type`.
*
* If conflation of stateful messages is required, a custom `Message`
* subclass should be created.
*/
export class ConflatableMessage extends Message {
/**
* Test whether the message is conflatable.
*
* #### Notes
* This property is always `true`.
*/
get isConflatable(): boolean {
return true;
}
/**
* Conflate this message with another message of the same `type`.
*
* #### Notes
* This method always returns `true`.
*/
conflate(other: ConflatableMessage): boolean {
return true;
}
}
/**
* An object which handles messages.
*
* #### Notes
* A message handler is a simple way of defining a type which can act
* upon on a large variety of external input without requiring a large
* abstract API surface. This is particularly useful in the context of
* widget frameworks where the number of distinct message types can be
* unbounded.
*/
export interface IMessageHandler {
/**
* Process a message sent to the handler.
*
* @param msg - The message to be processed.
*/
processMessage(msg: Message): void;
}
/**
* An object which intercepts messages sent to a message handler.
*
* #### Notes
* A message hook is useful for intercepting or spying on messages
* sent to message handlers which were either not created by the
* consumer, or when subclassing the handler is not feasible.
*
* If `messageHook` returns `false`, no other message hooks will be
* invoked and the message will not be delivered to the handler.
*
* If all installed message hooks return `true`, the message will
* be delivered to the handler for processing.
*
* **See also:** {@link MessageLoop.installMessageHook} and {@link MessageLoop.removeMessageHook}
*/
export interface IMessageHook {
/**
* Intercept a message sent to a message handler.
*
* @param handler - The target handler of the message.
*
* @param msg - The message to be sent to the handler.
*
* @returns `true` if the message should continue to be processed
* as normal, or `false` if processing should cease immediately.
*/
messageHook(handler: IMessageHandler, msg: Message): boolean;
}
/**
* A type alias for message hook object or function.
*
* #### Notes
* The signature and semantics of a message hook function are the same
* as the `messageHook` method of {@link IMessageHook}.
*/
export type MessageHook =
| IMessageHook
| ((handler: IMessageHandler, msg: Message) => boolean);
/**
* The namespace for the global singleton message loop.
*/
export namespace MessageLoop {
/**
* A function that cancels the pending loop task; `null` if unavailable.
*/
let pending: (() => void) | null = null;
/**
* Schedules a function for invocation as soon as possible asynchronously.
*
* @param fn The function to invoke when called back.
*
* @returns An anonymous function that will unschedule invocation if possible.
*/
const schedule = (
resolved =>
(fn: () => unknown): (() => void) => {
let rejected = false;
resolved.then(() => !rejected && fn());
return () => {
rejected = true;
};
}
)(Promise.resolve());
/**
* Send a message to a message handler to process immediately.
*
* @param handler - The handler which should process the message.
*
* @param msg - The message to deliver to the handler.
*
* #### Notes
* The message will first be sent through any installed message hooks
* for the handler. If the message passes all hooks, it will then be
* delivered to the `processMessage` method of the handler.
*
* The message will not be conflated with pending posted messages.
*
* Exceptions in hooks and handlers will be caught and logged.
*/
export function sendMessage(handler: IMessageHandler, msg: Message): void {
// Lookup the message hooks for the handler.
const hooks = messageHooks.get(handler);
// Handle the common case of no installed hooks.
if (!hooks || hooks.length === 0) {
invokeHandler(handler, msg);
return;
}
// Invoke the message hooks starting with the newest first.
const passed = every(retro(hooks), hook =>
hook ? invokeHook(hook, handler, msg) : true,
);
// Invoke the handler if the message passes all hooks.
if (passed) {
invokeHandler(handler, msg);
}
}
/**
* Post a message to a message handler to process in the future.
*
* @param handler - The handler which should process the message.
*
* @param msg - The message to post to the handler.
*
* #### Notes
* The message will be conflated with the pending posted messages for
* the handler, if possible. If the message is not conflated, it will
* be queued for normal delivery on the next cycle of the event loop.
*
* Exceptions in hooks and handlers will be caught and logged.
*/
export function postMessage(handler: IMessageHandler, msg: Message): void {
// Handle the common case of a non-conflatable message.
if (!msg.isConflatable) {
enqueueMessage(handler, msg);
return;
}
// Conflate the message with an existing message if possible.
const conflated = some(messageQueue, posted => {
if (posted.handler !== handler) {
return false;
}
if (!posted.msg) {
return false;
}
if (posted.msg.type !== msg.type) {
return false;
}
if (!posted.msg.isConflatable) {
return false;
}
return posted.msg.conflate(msg);
});
// Enqueue the message if it was not conflated.
if (!conflated) {
enqueueMessage(handler, msg);
}
}
/**
* Install a message hook for a message handler.
*
* @param handler - The message handler of interest.
*
* @param hook - The message hook to install.
*
* #### Notes
* A message hook is invoked before a message is delivered to the
* handler. If the hook returns `false`, no other hooks will be
* invoked and the message will not be delivered to the handler.
*
* The most recently installed message hook is executed first.
*
* If the hook is already installed, this is a no-op.
*/
export function installMessageHook(
handler: IMessageHandler,
hook: MessageHook,
): void {
// Look up the hooks for the handler.
const hooks = messageHooks.get(handler);
// Bail early if the hook is already installed.
if (hooks && hooks.indexOf(hook) !== -1) {
return;
}
// Add the hook to the end, so it will be the first to execute.
if (!hooks) {
messageHooks.set(handler, [hook]);
} else {
hooks.push(hook);
}
}
/**
* Remove an installed message hook for a message handler.
*
* @param handler - The message handler of interest.
*
* @param hook - The message hook to remove.
*
* #### Notes
* It is safe to call this function while the hook is executing.
*
* If the hook is not installed, this is a no-op.
*/
export function removeMessageHook(
handler: IMessageHandler,
hook: MessageHook,
): void {
// Lookup the hooks for the handler.
const hooks = messageHooks.get(handler);
// Bail early if the hooks do not exist.
if (!hooks) {
return;
}
// Lookup the index of the hook and bail if not found.
const i = hooks.indexOf(hook);
if (i === -1) {
return;
}
// Clear the hook and schedule a cleanup of the array.
hooks[i] = null;
scheduleCleanup(hooks);
}
/**
* Clear all message data associated with a message handler.
*
* @param handler - The message handler of interest.
*
* #### Notes
* This will clear all posted messages and hooks for the handler.
*/
export function clearData(handler: IMessageHandler): void {
// Lookup the hooks for the handler.
const hooks = messageHooks.get(handler);
// Clear all messsage hooks for the handler.
if (hooks && hooks.length > 0) {
ArrayExt.fill(hooks, null);
scheduleCleanup(hooks);
}
// Clear all posted messages for the handler.
for (const posted of messageQueue) {
if (posted.handler === handler) {
posted.handler = null;
posted.msg = null;
}
}
}
/**
* Process the pending posted messages in the queue immediately.
*
* #### Notes
* This function is useful when posted messages must be processed immediately.
*
* This function should normally not be needed, but it may be
* required to work around certain browser idiosyncrasies.
*
* Recursing into this function is a no-op.
*/
export function flush(): void {
// Bail if recursion is detected or if there is no pending task.
if (flushGuard || pending === null) {
return;
}
// Unschedule the pending loop task.
pending();
pending = null;
// Run the message loop within the recursion guard.
flushGuard = true;
runMessageLoop();
flushGuard = false;
}
/**
* A type alias for the exception handler function.
*/
export type ExceptionHandler = (err: Error) => void;
/**
* Get the message loop exception handler.
*
* @returns The current exception handler.
*
* #### Notes
* The default exception handler is `console.error`.
*/
export function getExceptionHandler(): ExceptionHandler {
return exceptionHandler;
}
/**
* Set the message loop exception handler.
*
* @param handler - The function to use as the exception handler.
*
* @returns The old exception handler.
*
* #### Notes
* The exception handler is invoked when a message handler or a
* message hook throws an exception.
*/
export function setExceptionHandler(
handler: ExceptionHandler,
): ExceptionHandler {
const old = exceptionHandler;
exceptionHandler = handler;
return old;
}
/**
* A type alias for a posted message pair.
*/
interface PostedMessage {
handler: IMessageHandler | null;
msg: Message | null;
}
/**
* The queue of posted message pairs.
*/
const messageQueue = new LinkedList<PostedMessage>();
/**
* A mapping of handler to array of installed message hooks.
*/
const messageHooks = new WeakMap<
IMessageHandler,
Array<MessageHook | null>
>();
/**
* A set of message hook arrays which are pending cleanup.
*/
const dirtySet = new Set<Array<MessageHook | null>>();
/**
* The message loop exception handler.
*/
let exceptionHandler: ExceptionHandler = (err: Error) => {
console.error(err);
};
/**
* A guard flag to prevent flush recursion.
*/
let flushGuard = false;
/**
* Invoke a message hook with the specified handler and message.
*
* Returns the result of the hook, or `true` if the hook throws.
*
* Exceptions in the hook will be caught and logged.
*/
function invokeHook(
hook: MessageHook,
handler: IMessageHandler,
msg: Message,
): boolean {
let result = true;
try {
if (typeof hook === 'function') {
result = hook(handler, msg);
} else {
result = hook.messageHook(handler, msg);
}
} catch (err) {
exceptionHandler(err as Error);
}
return result;
}
/**
* Invoke a message handler with the specified message.
*
* Exceptions in the handler will be caught and logged.
*/
function invokeHandler(handler: IMessageHandler, msg: Message): void {
try {
handler.processMessage(msg);
} catch (err) {
exceptionHandler(err as Error);
}
}
/**
* Add a message to the end of the message queue.
*
* This will automatically schedule a run of the message loop.
*/
function enqueueMessage(handler: IMessageHandler, msg: Message): void {
// Add the posted message to the queue.
messageQueue.addLast({ handler, msg });
// Bail if a loop task is already pending.
if (pending !== null) {
return;
}
// Schedule a run of the message loop.
pending = schedule(runMessageLoop);
}
/**
* Run an iteration of the message loop.
*
* This will process all pending messages in the queue. If a message
* is added to the queue while the message loop is running, it will
* be processed on the next cycle of the loop.
*/
function runMessageLoop(): void {
// Clear the task so the next loop can be scheduled.
pending = null;
// If the message queue is empty, there is nothing else to do.
if (messageQueue.isEmpty) {
return;
}
// Add a sentinel value to the end of the queue. The queue will
// only be processed up to the sentinel. Messages posted during
// this cycle will execute on the next cycle.
const sentinel: PostedMessage = { handler: null, msg: null };
messageQueue.addLast(sentinel);
// Enter the message loop.
while (true) {
// Remove the first posted message in the queue.
const posted = messageQueue.removeFirst()!;
// If the value is the sentinel, exit the loop.
if (posted === sentinel) {
return;
}
// Dispatch the message if it has not been cleared.
if (posted.handler && posted.msg) {
sendMessage(posted.handler, posted.msg);
}
}
}
/**
* Schedule a cleanup of a message hooks array.
*
* This will add the array to the dirty set and schedule a deferred
* cleanup of the array contents. On cleanup, any `null` hook will
* be removed from the array.
*/
function scheduleCleanup(hooks: Array<MessageHook | null>): void {
if (dirtySet.size === 0) {
schedule(cleanupDirtySet);
}
dirtySet.add(hooks);
}
/**
* Cleanup the message hook arrays in the dirty set.
*
* This function should only be invoked asynchronously, when the
* stack frame is guaranteed to not be on the path of user code.
*/
function cleanupDirtySet(): void {
dirtySet.forEach(cleanupHooks);
dirtySet.clear();
}
/**
* Cleanup the dirty hooks in a message hooks array.
*
* This will remove any `null` hook from the array.
*
* This function should only be invoked asynchronously, when the
* stack frame is guaranteed to not be on the path of user code.
*/
function cleanupHooks(hooks: Array<MessageHook | null>): void {
ArrayExt.removeAllWhere(hooks, isNull);
}
/**
* Test whether a value is `null`.
*/
function isNull<T>(value: T | null): boolean {
return value === null;
}
}

View File

@@ -0,0 +1,297 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// Copyright (c) Jupyter Development Team.
// Distributed under the terms of the Modified BSD License.
/*-----------------------------------------------------------------------------
| Copyright (c) 2014-2017, PhosphorJS Contributors
|
| Distributed under the terms of the BSD 3-Clause License.
|
| The full license is in the file LICENSE, distributed with this software.
|----------------------------------------------------------------------------*/
/**
* @packageDocumentation
* @module properties
*/
/**
* A class which attaches a value to an external object.
*
* #### Notes
* Attached properties are used to extend the state of an object with
* semantic data from an unrelated class. They also encapsulate value
* creation, coercion, and notification.
*
* Because attached property values are stored in a hash table, which
* in turn is stored in a WeakMap keyed on the owner object, there is
* non-trivial storage overhead involved in their use. The pattern is
* therefore best used for the storage of rare data.
*/
export class AttachedProperty<T, U> {
/**
* Construct a new attached property.
*
* @param options - The options for initializing the property.
*/
constructor(options: AttachedProperty.IOptions<T, U>) {
this.name = options.name;
this._create = options.create;
this._coerce = options.coerce || null;
this._compare = options.compare || null;
this._changed = options.changed || null;
}
/**
* The human readable name for the property.
*/
readonly name: string;
/**
* Get the current value of the property for a given owner.
*
* @param owner - The property owner of interest.
*
* @returns The current value of the property.
*
* #### Notes
* If the value has not yet been set, the default value will be
* computed and assigned as the current value of the property.
*/
get(owner: T): U {
let value: U;
const map = Private.ensureMap(owner);
if (this._pid in map) {
value = map[this._pid];
} else {
value = map[this._pid] = this._createValue(owner);
}
return value;
}
/**
* Set the current value of the property for a given owner.
*
* @param owner - The property owner of interest.
*
* @param value - The value for the property.
*
* #### Notes
* If the value has not yet been set, the default value will be
* computed and used as the previous value for the comparison.
*/
set(owner: T, value: U): void {
let oldValue: U;
const map = Private.ensureMap(owner);
if (this._pid in map) {
oldValue = map[this._pid];
} else {
oldValue = map[this._pid] = this._createValue(owner);
}
const newValue = this._coerceValue(owner, value);
this._maybeNotify(owner, oldValue, (map[this._pid] = newValue));
}
/**
* Explicitly coerce the current property value for a given owner.
*
* @param owner - The property owner of interest.
*
* #### Notes
* If the value has not yet been set, the default value will be
* computed and used as the previous value for the comparison.
*/
coerce(owner: T): void {
let oldValue: U;
const map = Private.ensureMap(owner);
if (this._pid in map) {
oldValue = map[this._pid];
} else {
oldValue = map[this._pid] = this._createValue(owner);
}
const newValue = this._coerceValue(owner, oldValue);
this._maybeNotify(owner, oldValue, (map[this._pid] = newValue));
}
/**
* Get or create the default value for the given owner.
*/
private _createValue(owner: T): U {
const create = this._create;
return create(owner);
}
/**
* Coerce the value for the given owner.
*/
private _coerceValue(owner: T, value: U): U {
const coerce = this._coerce;
return coerce ? coerce(owner, value) : value;
}
/**
* Compare the old value and new value for equality.
*/
private _compareValue(oldValue: U, newValue: U): boolean {
const compare = this._compare;
return compare ? compare(oldValue, newValue) : oldValue === newValue;
}
/**
* Run the change notification if the given values are different.
*/
private _maybeNotify(owner: T, oldValue: U, newValue: U): void {
const changed = this._changed;
if (changed && !this._compareValue(oldValue, newValue)) {
changed(owner, oldValue, newValue);
}
}
private _pid = Private.nextPID();
private _create: (owner: T) => U;
private _coerce: ((owner: T, value: U) => U) | null;
private _compare: ((oldValue: U, newValue: U) => boolean) | null;
private _changed: ((owner: T, oldValue: U, newValue: U) => void) | null;
}
/**
* The namespace for the `AttachedProperty` class statics.
*/
export namespace AttachedProperty {
/**
* The options object used to initialize an attached property.
*/
export interface IOptions<T, U> {
/**
* The human readable name for the property.
*
* #### Notes
* By convention, this should be the same as the name used to define
* the public accessor for the property value.
*
* This **does not** have an effect on the property lookup behavior.
* Multiple properties may share the same name without conflict.
*/
name: string;
/**
* A factory function used to create the default property value.
*
* #### Notes
* This will be called whenever the property value is required,
* but has not yet been set for a given owner.
*/
create: (owner: T) => U;
/**
* A function used to coerce a supplied value into the final value.
*
* #### Notes
* This will be called whenever the property value is changed, or
* when the property is explicitly coerced. The return value will
* be used as the final value of the property.
*
* This will **not** be called for the initial default value.
*/
coerce?: (owner: T, value: U) => U;
/**
* A function used to compare two values for equality.
*
* #### Notes
* This is called to determine if the property value has changed.
* It should return `true` if the given values are equivalent, or
* `false` if they are different.
*
* If this is not provided, it defaults to the `===` operator.
*/
compare?: (oldValue: U, newValue: U) => boolean;
/**
* A function called when the property value has changed.
*
* #### Notes
* This will be invoked when the property value is changed and the
* comparator indicates that the old value is not equal to the new
* value.
*
* This will **not** be called for the initial default value.
*/
changed?: (owner: T, oldValue: U, newValue: U) => void;
}
/**
* Clear the stored property data for the given owner.
*
* @param owner - The property owner of interest.
*
* #### Notes
* This will clear all property values for the owner, but it will
* **not** run the change notification for any of the properties.
*/
export function clearData(owner: unknown): void {
Private.ownerData.delete(owner);
}
}
/**
* The namespace for the module implementation details.
*/
namespace Private {
/**
* A typedef for a mapping of property id to property value.
*/
export interface PropertyMap {
[key: string]: any;
}
/**
* A weak mapping of property owner to property map.
*/
export const ownerData = new WeakMap<any, PropertyMap>();
/**
* A function which computes successive unique property ids.
*/
export const nextPID = (() => {
let id = 0;
return () => {
const rand = Math.random();
const stem = `${rand}`.slice(2);
return `pid-${stem}-${id++}`;
};
})();
/**
* Lookup the data map for the property owner.
*
* This will create the map if one does not already exist.
*/
export function ensureMap(owner: unknown): PropertyMap {
let map = ownerData.get(owner);
if (map) {
return map;
}
map = Object.create(null) as PropertyMap;
ownerData.set(owner, map);
return map;
}
}

View File

@@ -0,0 +1,785 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// Copyright (c) Jupyter Development Team.
// Distributed under the terms of the Modified BSD License.
/*-----------------------------------------------------------------------------
| Copyright (c) 2014-2017, PhosphorJS Contributors
|
| Distributed under the terms of the BSD 3-Clause License.
|
| The full license is in the file LICENSE, distributed with this software.
|----------------------------------------------------------------------------*/
/**
* @packageDocumentation
* @module signaling
*/
import { PromiseDelegate } from '../coreutils';
import { ArrayExt, find } from '../algorithm';
/**
* A type alias for a slot function.
*
* @param sender - The object emitting the signal.
*
* @param args - The args object emitted with the signal.
*
* #### Notes
* A slot is invoked when a signal to which it is connected is emitted.
*/
export type Slot<T, U> = (sender: T, args: U) => void;
/**
* An object used for type-safe inter-object communication.
*
* #### Notes
* Signals provide a type-safe implementation of the publish-subscribe
* pattern. An object (publisher) declares which signals it will emit,
* and consumers connect callbacks (subscribers) to those signals. The
* subscribers are invoked whenever the publisher emits the signal.
*/
export interface ISignal<T, U> {
/**
* Connect a slot to the signal.
*
* @param slot - The slot to invoke when the signal is emitted.
*
* @param thisArg - The `this` context for the slot. If provided,
* this must be a non-primitive object.
*
* @returns `true` if the connection succeeds, `false` otherwise.
*
* #### Notes
* Slots are invoked in the order in which they are connected.
*
* Signal connections are unique. If a connection already exists for
* the given `slot` and `thisArg`, this method returns `false`.
*
* A newly connected slot will not be invoked until the next time the
* signal is emitted, even if the slot is connected while the signal
* is dispatching.
*/
connect(slot: Slot<T, U>, thisArg?: any): boolean;
/**
* Disconnect a slot from the signal.
*
* @param slot - The slot to disconnect from the signal.
*
* @param thisArg - The `this` context for the slot. If provided,
* this must be a non-primitive object.
*
* @returns `true` if the connection is removed, `false` otherwise.
*
* #### Notes
* If no connection exists for the given `slot` and `thisArg`, this
* method returns `false`.
*
* A disconnected slot will no longer be invoked, even if the slot
* is disconnected while the signal is dispatching.
*/
disconnect(slot: Slot<T, U>, thisArg?: any): boolean;
}
/**
* An object that is both a signal and an async iterable.
*/
export interface IStream<T, U> extends ISignal<T, U>, AsyncIterable<U> {}
/**
* A concrete implementation of `ISignal`.
*
* #### Example
* ```typescript
* import { ISignal, Signal } from '../signaling';
*
* class SomeClass {
*
* constructor(name: string) {
* this.name = name;
* }
*
* readonly name: string;
*
* get valueChanged: ISignal<this, number> {
* return this._valueChanged;
* }
*
* get value(): number {
* return this._value;
* }
*
* set value(value: number) {
* if (value === this._value) {
* return;
* }
* this._value = value;
* this._valueChanged.emit(value);
* }
*
* private _value = 0;
* private _valueChanged = new Signal<this, number>(this);
* }
*
* function logger(sender: SomeClass, value: number): void {
* console.log(sender.name, value);
* }
*
* let m1 = new SomeClass('foo');
* let m2 = new SomeClass('bar');
*
* m1.valueChanged.connect(logger);
* m2.valueChanged.connect(logger);
*
* m1.value = 42; // logs: foo 42
* m2.value = 17; // logs: bar 17
* ```
*/
export class Signal<T, U> implements ISignal<T, U> {
/**
* Construct a new signal.
*
* @param sender - The sender which owns the signal.
*/
constructor(sender: T) {
this.sender = sender;
}
/**
* The sender which owns the signal.
*/
readonly sender: T;
/**
* Connect a slot to the signal.
*
* @param slot - The slot to invoke when the signal is emitted.
*
* @param thisArg - The `this` context for the slot. If provided,
* this must be a non-primitive object.
*
* @returns `true` if the connection succeeds, `false` otherwise.
*/
connect(slot: Slot<T, U>, thisArg?: unknown): boolean {
return Private.connect(this, slot, thisArg);
}
/**
* Disconnect a slot from the signal.
*
* @param slot - The slot to disconnect from the signal.
*
* @param thisArg - The `this` context for the slot. If provided,
* this must be a non-primitive object.
*
* @returns `true` if the connection is removed, `false` otherwise.
*/
disconnect(slot: Slot<T, U>, thisArg?: unknown): boolean {
return Private.disconnect(this, slot, thisArg);
}
/**
* Emit the signal and invoke the connected slots.
*
* @param args - The args to pass to the connected slots.
*
* #### Notes
* Slots are invoked synchronously in connection order.
*
* Exceptions thrown by connected slots will be caught and logged.
*/
emit(args: U): void {
Private.emit(this, args);
}
}
/**
* The namespace for the `Signal` class statics.
*/
export namespace Signal {
/**
* Remove all connections between a sender and receiver.
*
* @param sender - The sender object of interest.
*
* @param receiver - The receiver object of interest.
*
* #### Notes
* If a `thisArg` is provided when connecting a signal, that object
* is considered the receiver. Otherwise, the `slot` is considered
* the receiver.
*/
export function disconnectBetween(sender: unknown, receiver: unknown): void {
Private.disconnectBetween(sender, receiver);
}
/**
* Remove all connections where the given object is the sender.
*
* @param sender - The sender object of interest.
*/
export function disconnectSender(sender: unknown): void {
Private.disconnectSender(sender);
}
/**
* Remove all connections where the given object is the receiver.
*
* @param receiver - The receiver object of interest.
*
* #### Notes
* If a `thisArg` is provided when connecting a signal, that object
* is considered the receiver. Otherwise, the `slot` is considered
* the receiver.
*/
export function disconnectReceiver(receiver: unknown): void {
Private.disconnectReceiver(receiver);
}
/**
* Remove all connections where an object is the sender or receiver.
*
* @param object - The object of interest.
*
* #### Notes
* If a `thisArg` is provided when connecting a signal, that object
* is considered the receiver. Otherwise, the `slot` is considered
* the receiver.
*/
export function disconnectAll(object: unknown): void {
Private.disconnectAll(object);
}
/**
* Clear all signal data associated with the given object.
*
* @param object - The object for which the data should be cleared.
*
* #### Notes
* This removes all signal connections and any other signal data
* associated with the object.
*/
export function clearData(object: unknown): void {
Private.disconnectAll(object);
}
/**
* A type alias for the exception handler function.
*/
export type ExceptionHandler = (err: Error) => void;
/**
* Get the signal exception handler.
*
* @returns The current exception handler.
*
* #### Notes
* The default exception handler is `console.error`.
*/
export function getExceptionHandler(): ExceptionHandler {
return Private.exceptionHandler;
}
/**
* Set the signal exception handler.
*
* @param handler - The function to use as the exception handler.
*
* @returns The old exception handler.
*
* #### Notes
* The exception handler is invoked when a slot throws an exception.
*/
export function setExceptionHandler(
handler: ExceptionHandler,
): ExceptionHandler {
const old = Private.exceptionHandler;
// eslint-disable-next-line @typescript-eslint/prefer-ts-expect-error
// @ts-ignore
Private.exceptionHandler = handler;
return old;
}
}
/**
* A concrete implementation of `IStream`.
*
* #### Example
* ```typescript
* import { IStream, Stream } from '../signaling';
*
* class SomeClass {
*
* constructor(name: string) {
* this.name = name;
* }
*
* readonly name: string;
*
* get pings(): IStream<this, string> {
* return this._pings;
* }
*
* ping(value: string) {
* this._pings.emit(value);
* }
*
* private _pings = new Stream<this, string>(this);
* }
*
* let m1 = new SomeClass('foo');
*
* m1.pings.connect((_, value: string) => {
* console.log('connect', value);
* });
*
* void (async () => {
* for await (const ping of m1.pings) {
* console.log('iterator', ping);
* }
* })();
*
* m1.ping('alpha'); // logs: connect alpha
* // logs: iterator alpha
* m1.ping('beta'); // logs: connect beta
* // logs: iterator beta
* ```
*/
export class Stream<T, U> extends Signal<T, U> implements IStream<T, U> {
/**
* Return an async iterator that yields every emission.
*/
async *[Symbol.asyncIterator](): AsyncIterableIterator<U> {
let pending = this._pending;
while (true) {
try {
const { args, next } = await pending.promise;
pending = next;
yield args;
} catch (_) {
return; // Any promise rejection stops the iterator.
}
}
}
/**
* Emit the signal, invoke the connected slots, and yield the emission.
*
* @param args - The args to pass to the connected slots.
*/
emit(args: U): void {
const pending = this._pending;
const next = (this._pending = new PromiseDelegate());
pending.resolve({ args, next });
super.emit(args);
}
/**
* Stop the stream's async iteration.
*/
stop(): void {
this._pending.promise.catch(() => undefined);
this._pending.reject('stop');
this._pending = new PromiseDelegate();
}
private _pending: Private.Pending<U> = new PromiseDelegate();
}
/**
* The namespace for the module implementation details.
*/
namespace Private {
/**
* A pending promise in a promise chain underlying a stream.
*/
export type Pending<U> = PromiseDelegate<{ args: U; next: Pending<U> }>;
/**
* The signal exception handler function.
*/
export const exceptionHandler: Signal.ExceptionHandler = (err: Error) => {
console.error(err);
};
/**
* Connect a slot to a signal.
*
* @param signal - The signal of interest.
*
* @param slot - The slot to invoke when the signal is emitted.
*
* @param thisArg - The `this` context for the slot. If provided,
* this must be a non-primitive object.
*
* @returns `true` if the connection succeeds, `false` otherwise.
*/
export function connect<T, U>(
signal: Signal<T, U>,
slot: Slot<T, U>,
thisArg?: unknown,
): boolean {
// Coerce a `null` `thisArg` to `undefined`.
thisArg = thisArg || undefined;
// Ensure the sender's array of receivers is created.
let receivers = receiversForSender.get(signal.sender);
if (!receivers) {
receivers = [];
receiversForSender.set(signal.sender, receivers);
}
// Bail if a matching connection already exists.
if (findConnection(receivers, signal, slot, thisArg)) {
return false;
}
// Choose the best object for the receiver.
const receiver = thisArg || slot;
// Ensure the receiver's array of senders is created.
let senders = sendersForReceiver.get(receiver);
if (!senders) {
senders = [];
sendersForReceiver.set(receiver, senders);
}
// Create a new connection and add it to the end of each array.
const connection = { signal, slot, thisArg };
receivers.push(connection);
senders.push(connection);
// Indicate a successful connection.
return true;
}
/**
* Disconnect a slot from a signal.
*
* @param signal - The signal of interest.
*
* @param slot - The slot to disconnect from the signal.
*
* @param thisArg - The `this` context for the slot. If provided,
* this must be a non-primitive object.
*
* @returns `true` if the connection is removed, `false` otherwise.
*/
export function disconnect<T, U>(
signal: Signal<T, U>,
slot: Slot<T, U>,
thisArg?: unknown,
): boolean {
// Coerce a `null` `thisArg` to `undefined`.
thisArg = thisArg || undefined;
// Lookup the list of receivers, and bail if none exist.
const receivers = receiversForSender.get(signal.sender);
if (!receivers || receivers.length === 0) {
return false;
}
// Bail if no matching connection exits.
const connection = findConnection(receivers, signal, slot, thisArg);
if (!connection) {
return false;
}
// Choose the best object for the receiver.
const receiver = thisArg || slot;
// Lookup the array of senders, which is now known to exist.
const senders = sendersForReceiver.get(receiver)!;
// Clear the connection and schedule cleanup of the arrays.
connection.signal = null;
scheduleCleanup(receivers);
scheduleCleanup(senders);
// Indicate a successful disconnection.
return true;
}
/**
* Remove all connections between a sender and receiver.
*
* @param sender - The sender object of interest.
*
* @param receiver - The receiver object of interest.
*/
export function disconnectBetween(sender: unknown, receiver: unknown): void {
// If there are no receivers, there is nothing to do.
const receivers = receiversForSender.get(sender);
if (!receivers || receivers.length === 0) {
return;
}
// If there are no senders, there is nothing to do.
const senders = sendersForReceiver.get(receiver);
if (!senders || senders.length === 0) {
return;
}
// Clear each connection between the sender and receiver.
for (const connection of senders) {
// Skip connections which have already been cleared.
if (!connection.signal) {
continue;
}
// Clear the connection if it matches the sender.
if (connection.signal.sender === sender) {
connection.signal = null;
}
}
// Schedule a cleanup of the senders and receivers.
scheduleCleanup(receivers);
scheduleCleanup(senders);
}
/**
* Remove all connections where the given object is the sender.
*
* @param sender - The sender object of interest.
*/
export function disconnectSender(sender: unknown): void {
// If there are no receivers, there is nothing to do.
const receivers = receiversForSender.get(sender);
if (!receivers || receivers.length === 0) {
return;
}
// Clear each receiver connection.
for (const connection of receivers) {
// Skip connections which have already been cleared.
if (!connection.signal) {
continue;
}
// Choose the best object for the receiver.
const receiver = connection.thisArg || connection.slot;
// Clear the connection.
connection.signal = null;
// Cleanup the array of senders, which is now known to exist.
scheduleCleanup(sendersForReceiver.get(receiver)!);
}
// Schedule a cleanup of the receivers.
scheduleCleanup(receivers);
}
/**
* Remove all connections where the given object is the receiver.
*
* @param receiver - The receiver object of interest.
*/
export function disconnectReceiver(receiver: unknown): void {
// If there are no senders, there is nothing to do.
const senders = sendersForReceiver.get(receiver);
if (!senders || senders.length === 0) {
return;
}
// Clear each sender connection.
for (const connection of senders) {
// Skip connections which have already been cleared.
if (!connection.signal) {
continue;
}
// Lookup the sender for the connection.
const { sender } = connection.signal;
// Clear the connection.
connection.signal = null;
// Cleanup the array of receivers, which is now known to exist.
scheduleCleanup(receiversForSender.get(sender)!);
}
// Schedule a cleanup of the list of senders.
scheduleCleanup(senders);
}
/**
* Remove all connections where an object is the sender or receiver.
*
* @param object - The object of interest.
*/
export function disconnectAll(object: unknown): void {
// Remove all connections where the given object is the sender.
disconnectSender(object);
// Remove all connections where the given object is the receiver.
disconnectReceiver(object);
}
/**
* Emit a signal and invoke its connected slots.
*
* @param signal - The signal of interest.
*
* @param args - The args to pass to the connected slots.
*
* #### Notes
* Slots are invoked synchronously in connection order.
*
* Exceptions thrown by connected slots will be caught and logged.
*/
export function emit<T, U>(signal: Signal<T, U>, args: U): void {
// If there are no receivers, there is nothing to do.
const receivers = receiversForSender.get(signal.sender);
if (!receivers || receivers.length === 0) {
return;
}
// Invoke the slots for connections with a matching signal.
// Any connections added during emission are not invoked.
for (let i = 0, n = receivers.length; i < n; ++i) {
const connection = receivers[i];
if (connection.signal === signal) {
invokeSlot(connection, args);
}
}
}
/**
* An object which holds connection data.
*/
interface IConnection {
/**
* The signal for the connection.
*
* A `null` signal indicates a cleared connection.
*/
signal: Signal<any, any> | null;
/**
* The slot connected to the signal.
*/
readonly slot: Slot<any, any>;
/**
* The `this` context for the slot.
*/
readonly thisArg: any;
}
/**
* A weak mapping of sender to array of receiver connections.
*/
const receiversForSender = new WeakMap<any, IConnection[]>();
/**
* A weak mapping of receiver to array of sender connections.
*/
const sendersForReceiver = new WeakMap<any, IConnection[]>();
/**
* A set of connection arrays which are pending cleanup.
*/
const dirtySet = new Set<IConnection[]>();
/**
* A function to schedule an event loop callback.
*/
const schedule = (() => {
const ok = typeof requestAnimationFrame === 'function';
return ok ? requestAnimationFrame : setImmediate;
})();
/**
* Find a connection which matches the given parameters.
*/
function findConnection(
connections: IConnection[],
signal: Signal<any, any>,
slot: Slot<any, any>,
thisArg: any,
): IConnection | undefined {
return find(
connections,
connection =>
connection.signal === signal &&
connection.slot === slot &&
connection.thisArg === thisArg,
);
}
/**
* Invoke a slot with the given parameters.
*
* The connection is assumed to be valid.
*
* Exceptions in the slot will be caught and logged.
*/
function invokeSlot(connection: IConnection, args: any): void {
const { signal, slot, thisArg } = connection;
try {
slot.call(thisArg, signal!.sender, args);
} catch (err: unknown) {
exceptionHandler(err as unknown as Error);
}
}
/**
* Schedule a cleanup of a connection array.
*
* This will add the array to the dirty set and schedule a deferred
* cleanup of the array contents. On cleanup, any connection with a
* `null` signal will be removed from the array.
*/
function scheduleCleanup(array: IConnection[]): void {
if (dirtySet.size === 0) {
schedule(cleanupDirtySet);
}
dirtySet.add(array);
}
/**
* Cleanup the connection lists in the dirty set.
*
* This function should only be invoked asynchronously, when the
* stack frame is guaranteed to not be on the path of user code.
*/
function cleanupDirtySet(): void {
dirtySet.forEach(cleanupConnections);
dirtySet.clear();
}
/**
* Cleanup the dirty connections in a connections array.
*
* This will remove any connection with a `null` signal.
*
* This function should only be invoked asynchronously, when the
* stack frame is guaranteed to not be on the path of user code.
*/
function cleanupConnections(connections: IConnection[]): void {
ArrayExt.removeAllWhere(connections, isDeadConnection);
}
/**
* Test whether a connection is dead.
*
* A dead connection has a `null` signal.
*/
function isDeadConnection(connection: IConnection): boolean {
return connection.signal === null;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,507 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// Copyright (c) Jupyter Development Team.
// Distributed under the terms of the Modified BSD License.
/*-----------------------------------------------------------------------------
| Copyright (c) 2014-2017, PhosphorJS Contributors
|
| Distributed under the terms of the BSD 3-Clause License.
|
| The full license is in the file LICENSE, distributed with this software.
|----------------------------------------------------------------------------*/
/**
* A sizer object for use with the box engine layout functions.
*
* #### Notes
* A box sizer holds the geometry information for an object along an
* arbitrary layout orientation.
*
* For best performance, this class should be treated as a raw data
* struct. It should not typically be subclassed.
*/
export class BoxSizer {
/**
* The preferred size for the sizer.
*
* #### Notes
* The sizer will be given this initial size subject to its size
* bounds. The sizer will not deviate from this size unless such
* deviation is required to fit into the available layout space.
*
* There is no limit to this value, but it will be clamped to the
* bounds defined by {@link minSize} and {@link maxSize}.
*
* The default value is `0`.
*/
sizeHint = 0;
/**
* The minimum size of the sizer.
*
* #### Notes
* The sizer will never be sized less than this value, even if
* it means the sizer will overflow the available layout space.
*
* It is assumed that this value lies in the range `[0, Infinity)`
* and that it is `<=` to {@link maxSize}. Failure to adhere to this
* constraint will yield undefined results.
*
* The default value is `0`.
*/
minSize = 0;
/**
* The maximum size of the sizer.
*
* #### Notes
* The sizer will never be sized greater than this value, even if
* it means the sizer will underflow the available layout space.
*
* It is assumed that this value lies in the range `[0, Infinity]`
* and that it is `>=` to {@link minSize}. Failure to adhere to this
* constraint will yield undefined results.
*
* The default value is `Infinity`.
*/
maxSize = Infinity;
/**
* The stretch factor for the sizer.
*
* #### Notes
* This controls how much the sizer stretches relative to its sibling
* sizers when layout space is distributed. A stretch factor of zero
* is special and will cause the sizer to only be resized after all
* other sizers with a stretch factor greater than zero have been
* resized to their limits.
*
* It is assumed that this value is an integer that lies in the range
* `[0, Infinity)`. Failure to adhere to this constraint will yield
* undefined results.
*
* The default value is `1`.
*/
stretch = 1;
/**
* The computed size of the sizer.
*
* #### Notes
* This value is the output of a call to {@link BoxEngine.calc}. It represents
* the computed size for the object along the layout orientation,
* and will always lie in the range `[minSize, maxSize]`.
*
* This value is output only.
*
* Changing this value will have no effect.
*/
size = 0;
/**
* An internal storage property for the layout algorithm.
*
* #### Notes
* This value is used as temporary storage by the layout algorithm.
*
* Changing this value will have no effect.
*/
done = false;
}
/**
* The namespace for the box engine layout functions.
*/
export namespace BoxEngine {
/**
* Calculate the optimal layout sizes for a sequence of box sizers.
*
* This distributes the available layout space among the box sizers
* according to the following algorithm:
*
* 1. Initialize the sizers's size to its size hint and compute the
* sums for each of size hint, min size, and max size.
*
* 2. If the total size hint equals the available space, return.
*
* 3. If the available space is less than the total min size, set all
* sizers to their min size and return.
*
* 4. If the available space is greater than the total max size, set
* all sizers to their max size and return.
*
* 5. If the layout space is less than the total size hint, distribute
* the negative delta as follows:
*
* a. Shrink each sizer with a stretch factor greater than zero by
* an amount proportional to the negative space and the sum of
* stretch factors. If the sizer reaches its min size, remove
* it and its stretch factor from the computation.
*
* b. If after adjusting all stretch sizers there remains negative
* space, distribute the space equally among the sizers with a
* stretch factor of zero. If a sizer reaches its min size,
* remove it from the computation.
*
* 6. If the layout space is greater than the total size hint,
* distribute the positive delta as follows:
*
* a. Expand each sizer with a stretch factor greater than zero by
* an amount proportional to the postive space and the sum of
* stretch factors. If the sizer reaches its max size, remove
* it and its stretch factor from the computation.
*
* b. If after adjusting all stretch sizers there remains positive
* space, distribute the space equally among the sizers with a
* stretch factor of zero. If a sizer reaches its max size,
* remove it from the computation.
*
* 7. return
*
* @param sizers - The sizers for a particular layout line.
*
* @param space - The available layout space for the sizers.
*
* @returns The delta between the provided available space and the
* actual consumed space. This value will be zero if the sizers
* can be adjusted to fit, negative if the available space is too
* small, and positive if the available space is too large.
*
* #### Notes
* The {@link BoxSizer.size} of each sizer is updated with the computed size.
*
* This function can be called at any time to recompute the layout for
* an existing sequence of sizers. The previously computed results will
* have no effect on the new output. It is therefore not necessary to
* create new sizer objects on each resize event.
*/
export function calc(sizers: ArrayLike<BoxSizer>, space: number): number {
// Bail early if there is nothing to do.
const count = sizers.length;
if (count === 0) {
return space;
}
// Setup the size and stretch counters.
let totalMin = 0;
let totalMax = 0;
let totalSize = 0;
let totalStretch = 0;
let stretchCount = 0;
// Setup the sizers and compute the totals.
for (let i = 0; i < count; ++i) {
const sizer = sizers[i];
const min = sizer.minSize;
const max = sizer.maxSize;
const hint = sizer.sizeHint;
sizer.done = false;
sizer.size = Math.max(min, Math.min(hint, max));
totalSize += sizer.size;
totalMin += min;
totalMax += max;
if (sizer.stretch > 0) {
totalStretch += sizer.stretch;
stretchCount++;
}
}
// If the space is equal to the total size, return early.
if (space === totalSize) {
return 0;
}
// If the space is less than the total min, minimize each sizer.
if (space <= totalMin) {
for (let i = 0; i < count; ++i) {
const sizer = sizers[i];
sizer.size = sizer.minSize;
}
return space - totalMin;
}
// If the space is greater than the total max, maximize each sizer.
if (space >= totalMax) {
for (let i = 0; i < count; ++i) {
const sizer = sizers[i];
sizer.size = sizer.maxSize;
}
return space - totalMax;
}
// The loops below perform sub-pixel precision sizing. A near zero
// value is used for compares instead of zero to ensure that the
// loop terminates when the subdivided space is reasonably small.
const nearZero = 0.01;
// A counter which is decremented each time a sizer is resized to
// its limit. This ensures the loops terminate even if there is
// space remaining to distribute.
let notDoneCount = count;
// Distribute negative delta space.
if (space < totalSize) {
// Shrink each stretchable sizer by an amount proportional to its
// stretch factor. If a sizer reaches its min size it's marked as
// done. The loop progresses in phases where each sizer is given
// a chance to consume its fair share for the pass, regardless of
// whether a sizer before it reached its limit. This continues
// until the stretchable sizers or the free space is exhausted.
let freeSpace = totalSize - space;
while (stretchCount > 0 && freeSpace > nearZero) {
const distSpace = freeSpace;
const distStretch = totalStretch;
for (let i = 0; i < count; ++i) {
const sizer = sizers[i];
if (sizer.done || sizer.stretch === 0) {
continue;
}
const amt = (sizer.stretch * distSpace) / distStretch;
if (sizer.size - amt <= sizer.minSize) {
freeSpace -= sizer.size - sizer.minSize;
totalStretch -= sizer.stretch;
sizer.size = sizer.minSize;
sizer.done = true;
notDoneCount--;
stretchCount--;
} else {
freeSpace -= amt;
sizer.size -= amt;
}
}
}
// Distribute any remaining space evenly among the non-stretchable
// sizers. This progresses in phases in the same manner as above.
while (notDoneCount > 0 && freeSpace > nearZero) {
const amt = freeSpace / notDoneCount;
for (let i = 0; i < count; ++i) {
const sizer = sizers[i];
if (sizer.done) {
continue;
}
if (sizer.size - amt <= sizer.minSize) {
freeSpace -= sizer.size - sizer.minSize;
sizer.size = sizer.minSize;
sizer.done = true;
notDoneCount--;
} else {
freeSpace -= amt;
sizer.size -= amt;
}
}
}
}
// Distribute positive delta space.
else {
// Expand each stretchable sizer by an amount proportional to its
// stretch factor. If a sizer reaches its max size it's marked as
// done. The loop progresses in phases where each sizer is given
// a chance to consume its fair share for the pass, regardless of
// whether a sizer before it reached its limit. This continues
// until the stretchable sizers or the free space is exhausted.
let freeSpace = space - totalSize;
while (stretchCount > 0 && freeSpace > nearZero) {
const distSpace = freeSpace;
const distStretch = totalStretch;
for (let i = 0; i < count; ++i) {
const sizer = sizers[i];
if (sizer.done || sizer.stretch === 0) {
continue;
}
const amt = (sizer.stretch * distSpace) / distStretch;
if (sizer.size + amt >= sizer.maxSize) {
freeSpace -= sizer.maxSize - sizer.size;
totalStretch -= sizer.stretch;
sizer.size = sizer.maxSize;
sizer.done = true;
notDoneCount--;
stretchCount--;
} else {
freeSpace -= amt;
sizer.size += amt;
}
}
}
// Distribute any remaining space evenly among the non-stretchable
// sizers. This progresses in phases in the same manner as above.
while (notDoneCount > 0 && freeSpace > nearZero) {
const amt = freeSpace / notDoneCount;
for (let i = 0; i < count; ++i) {
const sizer = sizers[i];
if (sizer.done) {
continue;
}
if (sizer.size + amt >= sizer.maxSize) {
freeSpace -= sizer.maxSize - sizer.size;
sizer.size = sizer.maxSize;
sizer.done = true;
notDoneCount--;
} else {
freeSpace -= amt;
sizer.size += amt;
}
}
}
}
// Indicate that the consumed space equals the available space.
return 0;
}
/**
* Adjust a sizer by a delta and update its neighbors accordingly.
*
* @param sizers - The sizers which should be adjusted.
*
* @param index - The index of the sizer to grow.
*
* @param delta - The amount to adjust the sizer, positive or negative.
*
* #### Notes
* This will adjust the indicated sizer by the specified amount, along
* with the sizes of the appropriate neighbors, subject to the limits
* specified by each of the sizers.
*
* This is useful when implementing box layouts where the boundaries
* between the sizers are interactively adjustable by the user.
*/
export function adjust(
sizers: ArrayLike<BoxSizer>,
index: number,
delta: number,
): void {
// Bail early when there is nothing to do.
if (sizers.length === 0 || delta === 0) {
return;
}
// Dispatch to the proper implementation.
if (delta > 0) {
growSizer(sizers, index, delta);
} else {
shrinkSizer(sizers, index, -delta);
}
}
/**
* Grow a sizer by a positive delta and adjust neighbors.
*/
function growSizer(
sizers: ArrayLike<BoxSizer>,
index: number,
delta: number,
): void {
// Compute how much the items to the left can expand.
let growLimit = 0;
for (let i = 0; i <= index; ++i) {
const sizer = sizers[i];
growLimit += sizer.maxSize - sizer.size;
}
// Compute how much the items to the right can shrink.
let shrinkLimit = 0;
for (let i = index + 1, n = sizers.length; i < n; ++i) {
const sizer = sizers[i];
shrinkLimit += sizer.size - sizer.minSize;
}
// Clamp the delta adjustment to the limits.
delta = Math.min(delta, growLimit, shrinkLimit);
// Grow the sizers to the left by the delta.
let grow = delta;
for (let i = index; i >= 0 && grow > 0; --i) {
const sizer = sizers[i];
const limit = sizer.maxSize - sizer.size;
if (limit >= grow) {
sizer.sizeHint = sizer.size + grow;
grow = 0;
} else {
sizer.sizeHint = sizer.size + limit;
grow -= limit;
}
}
// Shrink the sizers to the right by the delta.
let shrink = delta;
for (let i = index + 1, n = sizers.length; i < n && shrink > 0; ++i) {
const sizer = sizers[i];
const limit = sizer.size - sizer.minSize;
if (limit >= shrink) {
sizer.sizeHint = sizer.size - shrink;
shrink = 0;
} else {
sizer.sizeHint = sizer.size - limit;
shrink -= limit;
}
}
}
/**
* Shrink a sizer by a positive delta and adjust neighbors.
*/
function shrinkSizer(
sizers: ArrayLike<BoxSizer>,
index: number,
delta: number,
): void {
// Compute how much the items to the right can expand.
let growLimit = 0;
for (let i = index + 1, n = sizers.length; i < n; ++i) {
const sizer = sizers[i];
growLimit += sizer.maxSize - sizer.size;
}
// Compute how much the items to the left can shrink.
let shrinkLimit = 0;
for (let i = 0; i <= index; ++i) {
const sizer = sizers[i];
shrinkLimit += sizer.size - sizer.minSize;
}
// Clamp the delta adjustment to the limits.
delta = Math.min(delta, growLimit, shrinkLimit);
// Grow the sizers to the right by the delta.
let grow = delta;
for (let i = index + 1, n = sizers.length; i < n && grow > 0; ++i) {
const sizer = sizers[i];
const limit = sizer.maxSize - sizer.size;
if (limit >= grow) {
sizer.sizeHint = sizer.size + grow;
grow = 0;
} else {
sizer.sizeHint = sizer.size + limit;
grow -= limit;
}
}
// Shrink the sizers to the left by the delta.
let shrink = delta;
for (let i = index; i >= 0 && shrink > 0; --i) {
const sizer = sizers[i];
const limit = sizer.size - sizer.minSize;
if (limit >= shrink) {
sizer.sizeHint = sizer.size - shrink;
shrink = 0;
} else {
sizer.sizeHint = sizer.size - limit;
shrink -= limit;
}
}
}
}

View File

@@ -0,0 +1,671 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// Copyright (c) Jupyter Development Team.
// Distributed under the terms of the Modified BSD License.
/*-----------------------------------------------------------------------------
| Copyright (c) 2014-2017, PhosphorJS Contributors
|
| Distributed under the terms of the BSD 3-Clause License.
|
| The full license is in the file LICENSE, distributed with this software.
|----------------------------------------------------------------------------*/
import { AttachedProperty } from '../properties';
import { type Message, MessageLoop } from '../messaging';
import { ElementExt } from '../domutils';
import { ArrayExt } from '../algorithm';
import { Widget } from './widget';
import Utils from './utils';
import { PanelLayout } from './panellayout';
import { LayoutItem } from './layout';
import { BoxEngine, BoxSizer } from './boxengine';
/**
* A layout which arranges its widgets in a single row or column.
*/
export class BoxLayout extends PanelLayout {
/**
* Construct a new box layout.
*
* @param options - The options for initializing the layout.
*/
constructor(options: BoxLayout.IOptions = {}) {
super();
if (options.direction !== undefined) {
this._direction = options.direction;
}
if (options.alignment !== undefined) {
this._alignment = options.alignment;
}
if (options.spacing !== undefined) {
this._spacing = Utils.clampDimension(options.spacing);
}
}
/**
* Dispose of the resources held by the layout.
*/
dispose(): void {
// Dispose of the layout items.
for (const item of this._items) {
item.dispose();
}
// Clear the layout state.
this._box = null;
this._items.length = 0;
this._sizers.length = 0;
// Dispose of the rest of the layout.
super.dispose();
}
/**
* Get the layout direction for the box layout.
*/
get direction(): BoxLayout.Direction {
return this._direction;
}
/**
* Set the layout direction for the box layout.
*/
set direction(value: BoxLayout.Direction) {
if (this._direction === value) {
return;
}
this._direction = value;
if (!this.parent) {
return;
}
this.parent.dataset.direction = value;
this.parent.fit();
}
/**
* Get the content alignment for the box layout.
*
* #### Notes
* This is the alignment of the widgets in the layout direction.
*
* The alignment has no effect if the widgets can expand to fill the
* entire box layout.
*/
get alignment(): BoxLayout.Alignment {
return this._alignment;
}
/**
* Set the content alignment for the box layout.
*
* #### Notes
* This is the alignment of the widgets in the layout direction.
*
* The alignment has no effect if the widgets can expand to fill the
* entire box layout.
*/
set alignment(value: BoxLayout.Alignment) {
if (this._alignment === value) {
return;
}
this._alignment = value;
if (!this.parent) {
return;
}
this.parent.dataset.alignment = value;
this.parent.update();
}
/**
* Get the inter-element spacing for the box layout.
*/
get spacing(): number {
return this._spacing;
}
/**
* Set the inter-element spacing for the box layout.
*/
set spacing(value: number) {
value = Utils.clampDimension(value);
if (this._spacing === value) {
return;
}
this._spacing = value;
if (!this.parent) {
return;
}
this.parent.fit();
}
/**
* Perform layout initialization which requires the parent widget.
*/
protected init(): void {
this.parent!.dataset.direction = this.direction;
this.parent!.dataset.alignment = this.alignment;
super.init();
}
/**
* Attach a widget to the parent's DOM node.
*
* @param index - The current index of the widget in the layout.
*
* @param widget - The widget to attach to the parent.
*
* #### Notes
* This is a reimplementation of the superclass method.
*/
protected attachWidget(index: number, widget: Widget): void {
// Create and add a new layout item for the widget.
ArrayExt.insert(this._items, index, new LayoutItem(widget));
// Create and add a new sizer for the widget.
ArrayExt.insert(this._sizers, index, new BoxSizer());
// Send a `'before-attach'` message if the parent is attached.
if (this.parent!.isAttached) {
MessageLoop.sendMessage(widget, Widget.Msg.BeforeAttach);
}
// Add the widget's node to the parent.
this.parent!.node.appendChild(widget.node);
// Send an `'after-attach'` message if the parent is attached.
if (this.parent!.isAttached) {
MessageLoop.sendMessage(widget, Widget.Msg.AfterAttach);
}
// Post a fit request for the parent widget.
this.parent!.fit();
}
/**
* Move a widget in the parent's DOM node.
*
* @param fromIndex - The previous index of the widget in the layout.
*
* @param toIndex - The current index of the widget in the layout.
*
* @param widget - The widget to move in the parent.
*
* #### Notes
* This is a reimplementation of the superclass method.
*/
protected moveWidget(
fromIndex: number,
toIndex: number,
widget: Widget,
): void {
// Move the layout item for the widget.
ArrayExt.move(this._items, fromIndex, toIndex);
// Move the sizer for the widget.
ArrayExt.move(this._sizers, fromIndex, toIndex);
// Post an update request for the parent widget.
this.parent!.update();
}
/**
* Detach a widget from the parent's DOM node.
*
* @param index - The previous index of the widget in the layout.
*
* @param widget - The widget to detach from the parent.
*
* #### Notes
* This is a reimplementation of the superclass method.
*/
protected detachWidget(index: number, widget: Widget): void {
// Remove the layout item for the widget.
const item = ArrayExt.removeAt(this._items, index);
// Remove the sizer for the widget.
ArrayExt.removeAt(this._sizers, index);
// Send a `'before-detach'` message if the parent is attached.
if (this.parent!.isAttached) {
MessageLoop.sendMessage(widget, Widget.Msg.BeforeDetach);
}
// Remove the widget's node from the parent.
this.parent!.node.removeChild(widget.node);
// Send an `'after-detach'` message if the parent is attached.
if (this.parent!.isAttached) {
MessageLoop.sendMessage(widget, Widget.Msg.AfterDetach);
}
// Dispose of the layout item.
item!.dispose();
// Post a fit request for the parent widget.
this.parent!.fit();
}
/**
* A message handler invoked on a `'before-show'` message.
*/
protected onBeforeShow(msg: Message): void {
super.onBeforeShow(msg);
this.parent!.update();
}
/**
* A message handler invoked on a `'before-attach'` message.
*/
protected onBeforeAttach(msg: Message): void {
super.onBeforeAttach(msg);
this.parent!.fit();
}
/**
* A message handler invoked on a `'child-shown'` message.
*/
protected onChildShown(msg: Widget.ChildMessage): void {
this.parent!.fit();
}
/**
* A message handler invoked on a `'child-hidden'` message.
*/
protected onChildHidden(msg: Widget.ChildMessage): void {
this.parent!.fit();
}
/**
* A message handler invoked on a `'resize'` message.
*/
protected onResize(msg: Widget.ResizeMessage): void {
if (this.parent!.isVisible) {
this._update(msg.width, msg.height);
}
}
/**
* A message handler invoked on an `'update-request'` message.
*/
protected onUpdateRequest(msg: Message): void {
if (this.parent!.isVisible) {
this._update(-1, -1);
}
}
/**
* A message handler invoked on a `'fit-request'` message.
*/
protected onFitRequest(msg: Message): void {
if (this.parent!.isAttached) {
this._fit();
}
}
/**
* Fit the layout to the total size required by the widgets.
*/
private _fit(): void {
// Compute the visible item count.
let nVisible = 0;
for (let i = 0, n = this._items.length; i < n; ++i) {
nVisible += +!this._items[i].isHidden;
}
// Update the fixed space for the visible items.
this._fixed = this._spacing * Math.max(0, nVisible - 1);
// Setup the computed minimum size.
const horz = Private.isHorizontal(this._direction);
let minW = horz ? this._fixed : 0;
let minH = horz ? 0 : this._fixed;
// Update the sizers and computed minimum size.
for (let i = 0, n = this._items.length; i < n; ++i) {
// Fetch the item and corresponding box sizer.
const item = this._items[i];
const sizer = this._sizers[i];
// If the item is hidden, it should consume zero size.
if (item.isHidden) {
sizer.minSize = 0;
sizer.maxSize = 0;
continue;
}
// Update the size limits for the item.
item.fit();
// Update the size basis and stretch factor.
sizer.sizeHint = BoxLayout.getSizeBasis(item.widget);
sizer.stretch = BoxLayout.getStretch(item.widget);
// Update the sizer limits and computed min size.
if (horz) {
sizer.minSize = item.minWidth;
sizer.maxSize = item.maxWidth;
minW += item.minWidth;
minH = Math.max(minH, item.minHeight);
} else {
sizer.minSize = item.minHeight;
sizer.maxSize = item.maxHeight;
minH += item.minHeight;
minW = Math.max(minW, item.minWidth);
}
}
// Update the box sizing and add it to the computed min size.
const box = (this._box = ElementExt.boxSizing(this.parent!.node));
minW += box.horizontalSum;
minH += box.verticalSum;
// Update the parent's min size constraints.
const { style } = this.parent!.node;
style.minWidth = `${minW}px`;
style.minHeight = `${minH}px`;
// Set the dirty flag to ensure only a single update occurs.
this._dirty = true;
// Notify the ancestor that it should fit immediately. This may
// cause a resize of the parent, fulfilling the required update.
if (this.parent!.parent) {
MessageLoop.sendMessage(this.parent!.parent!, Widget.Msg.FitRequest);
}
// If the dirty flag is still set, the parent was not resized.
// Trigger the required update on the parent widget immediately.
if (this._dirty) {
MessageLoop.sendMessage(this.parent!, Widget.Msg.UpdateRequest);
}
}
/**
* Update the layout position and size of the widgets.
*
* The parent offset dimensions should be `-1` if unknown.
*/
private _update(offsetWidth: number, offsetHeight: number): void {
// Clear the dirty flag to indicate the update occurred.
this._dirty = false;
// Compute the visible item count.
let nVisible = 0;
for (let i = 0, n = this._items.length; i < n; ++i) {
nVisible += +!this._items[i].isHidden;
}
// Bail early if there are no visible items to layout.
if (nVisible === 0) {
return;
}
// Measure the parent if the offset dimensions are unknown.
if (offsetWidth < 0) {
offsetWidth = this.parent!.node.offsetWidth;
}
if (offsetHeight < 0) {
offsetHeight = this.parent!.node.offsetHeight;
}
// Ensure the parent box sizing data is computed.
if (!this._box) {
this._box = ElementExt.boxSizing(this.parent!.node);
}
// Compute the layout area adjusted for border and padding.
let top = this._box.paddingTop;
let left = this._box.paddingLeft;
const width = offsetWidth - this._box.horizontalSum;
const height = offsetHeight - this._box.verticalSum;
// Distribute the layout space and adjust the start position.
let delta: number;
switch (this._direction) {
case 'left-to-right':
delta = BoxEngine.calc(this._sizers, Math.max(0, width - this._fixed));
break;
case 'top-to-bottom':
delta = BoxEngine.calc(this._sizers, Math.max(0, height - this._fixed));
break;
case 'right-to-left':
delta = BoxEngine.calc(this._sizers, Math.max(0, width - this._fixed));
left += width;
break;
case 'bottom-to-top':
delta = BoxEngine.calc(this._sizers, Math.max(0, height - this._fixed));
top += height;
break;
default:
throw 'unreachable';
}
// Setup the variables for justification and alignment offset.
let extra = 0;
let offset = 0;
// Account for alignment if there is extra layout space.
if (delta > 0) {
switch (this._alignment) {
case 'start':
break;
case 'center':
extra = 0;
offset = delta / 2;
break;
case 'end':
extra = 0;
offset = delta;
break;
case 'justify':
extra = delta / nVisible;
offset = 0;
break;
default:
throw 'unreachable';
}
}
// Layout the items using the computed box sizes.
for (let i = 0, n = this._items.length; i < n; ++i) {
// Fetch the item.
const item = this._items[i];
// Ignore hidden items.
if (item.isHidden) {
continue;
}
// Fetch the computed size for the widget.
const { size } = this._sizers[i];
// Update the widget geometry and advance the relevant edge.
switch (this._direction) {
case 'left-to-right':
item.update(left + offset, top, size + extra, height);
left += size + extra + this._spacing;
break;
case 'top-to-bottom':
item.update(left, top + offset, width, size + extra);
top += size + extra + this._spacing;
break;
case 'right-to-left':
item.update(left - offset - size - extra, top, size + extra, height);
left -= size + extra + this._spacing;
break;
case 'bottom-to-top':
item.update(left, top - offset - size - extra, width, size + extra);
top -= size + extra + this._spacing;
break;
default:
throw 'unreachable';
}
}
}
private _fixed = 0;
private _spacing = 4;
private _dirty = false;
private _sizers: BoxSizer[] = [];
private _items: LayoutItem[] = [];
private _box: ElementExt.IBoxSizing | null = null;
private _alignment: BoxLayout.Alignment = 'start';
private _direction: BoxLayout.Direction = 'top-to-bottom';
}
/**
* The namespace for the `BoxLayout` class statics.
*/
export namespace BoxLayout {
/**
* A type alias for a box layout direction.
*/
export type Direction =
| 'left-to-right'
| 'right-to-left'
| 'top-to-bottom'
| 'bottom-to-top';
/**
* A type alias for a box layout alignment.
*/
export type Alignment = 'start' | 'center' | 'end' | 'justify';
/**
* An options object for initializing a box layout.
*/
export interface IOptions {
/**
* The direction of the layout.
*
* The default is `'top-to-bottom'`.
*/
direction?: Direction;
/**
* The content alignment of the layout.
*
* The default is `'start'`.
*/
alignment?: Alignment;
/**
* The spacing between items in the layout.
*
* The default is `4`.
*/
spacing?: number;
}
/**
* Get the box layout stretch factor for the given widget.
*
* @param widget - The widget of interest.
*
* @returns The box layout stretch factor for the widget.
*/
export function getStretch(widget: Widget): number {
return Private.stretchProperty.get(widget);
}
/**
* Set the box layout stretch factor for the given widget.
*
* @param widget - The widget of interest.
*
* @param value - The value for the stretch factor.
*/
export function setStretch(widget: Widget, value: number): void {
Private.stretchProperty.set(widget, value);
}
/**
* Get the box layout size basis for the given widget.
*
* @param widget - The widget of interest.
*
* @returns The box layout size basis for the widget.
*/
export function getSizeBasis(widget: Widget): number {
return Private.sizeBasisProperty.get(widget);
}
/**
* Set the box layout size basis for the given widget.
*
* @param widget - The widget of interest.
*
* @param value - The value for the size basis.
*/
export function setSizeBasis(widget: Widget, value: number): void {
Private.sizeBasisProperty.set(widget, value);
}
}
/**
* The namespace for the module implementation details.
*/
namespace Private {
/**
* The property descriptor for a widget stretch factor.
*/
export const stretchProperty = new AttachedProperty<Widget, number>({
name: 'stretch',
create: () => 0,
coerce: (owner, value) => Math.max(0, Math.floor(value)),
changed: onChildSizingChanged,
});
/**
* The property descriptor for a widget size basis.
*/
export const sizeBasisProperty = new AttachedProperty<Widget, number>({
name: 'sizeBasis',
create: () => 0,
coerce: (owner, value) => Math.max(0, Math.floor(value)),
changed: onChildSizingChanged,
});
/**
* Test whether a direction has horizontal orientation.
*/
export function isHorizontal(dir: BoxLayout.Direction): boolean {
return dir === 'left-to-right' || dir === 'right-to-left';
}
/**
* Clamp a spacing value to an integer >= 0.
*/
export function clampSpacing(value: number): number {
return Math.max(0, Math.floor(value));
}
/**
* The change handler for the attached sizing properties.
*/
function onChildSizingChanged(child: Widget): void {
if (child.parent && child.parent.layout instanceof BoxLayout) {
child.parent.fit();
}
}
}

View File

@@ -0,0 +1,220 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// Copyright (c) Jupyter Development Team.
// Distributed under the terms of the Modified BSD License.
/*-----------------------------------------------------------------------------
| Copyright (c) 2014-2017, PhosphorJS Contributors
|
| Distributed under the terms of the BSD 3-Clause License.
|
| The full license is in the file LICENSE, distributed with this software.
|----------------------------------------------------------------------------*/
import { type Widget } from './widget';
import { Panel } from './panel';
import { BoxLayout } from './boxlayout';
/**
* A panel which arranges its widgets in a single row or column.
*
* #### Notes
* This class provides a convenience wrapper around a {@link BoxLayout}.
*/
export class BoxPanel extends Panel {
/**
* Construct a new box panel.
*
* @param options - The options for initializing the box panel.
*/
constructor(options: BoxPanel.IOptions = {}) {
super({ layout: Private.createLayout(options) });
this.addClass('lm-BoxPanel');
}
/**
* Get the layout direction for the box panel.
*/
get direction(): BoxPanel.Direction {
return (this.layout as BoxLayout).direction;
}
/**
* Set the layout direction for the box panel.
*/
set direction(value: BoxPanel.Direction) {
(this.layout as BoxLayout).direction = value;
}
/**
* Get the content alignment for the box panel.
*
* #### Notes
* This is the alignment of the widgets in the layout direction.
*
* The alignment has no effect if the widgets can expand to fill the
* entire box layout.
*/
get alignment(): BoxPanel.Alignment {
return (this.layout as BoxLayout).alignment;
}
/**
* Set the content alignment for the box panel.
*
* #### Notes
* This is the alignment of the widgets in the layout direction.
*
* The alignment has no effect if the widgets can expand to fill the
* entire box layout.
*/
set alignment(value: BoxPanel.Alignment) {
(this.layout as BoxLayout).alignment = value;
}
/**
* Get the inter-element spacing for the box panel.
*/
get spacing(): number {
return (this.layout as BoxLayout).spacing;
}
/**
* Set the inter-element spacing for the box panel.
*/
set spacing(value: number) {
(this.layout as BoxLayout).spacing = value;
}
/**
* A message handler invoked on a `'child-added'` message.
*/
protected onChildAdded(msg: Widget.ChildMessage): void {
msg.child.addClass('lm-BoxPanel-child');
}
/**
* A message handler invoked on a `'child-removed'` message.
*/
protected onChildRemoved(msg: Widget.ChildMessage): void {
msg.child.removeClass('lm-BoxPanel-child');
}
}
/**
* The namespace for the `BoxPanel` class statics.
*/
export namespace BoxPanel {
/**
* A type alias for a box panel direction.
*/
export type Direction = BoxLayout.Direction;
/**
* A type alias for a box panel alignment.
*/
export type Alignment = BoxLayout.Alignment;
/**
* An options object for initializing a box panel.
*/
export interface IOptions {
/**
* The layout direction of the panel.
*
* The default is `'top-to-bottom'`.
*/
direction?: Direction;
/**
* The content alignment of the panel.
*
* The default is `'start'`.
*/
alignment?: Alignment;
/**
* The spacing between items in the panel.
*
* The default is `4`.
*/
spacing?: number;
/**
* The box layout to use for the box panel.
*
* If this is provided, the other options are ignored.
*
* The default is a new `BoxLayout`.
*/
layout?: BoxLayout;
}
/**
* Get the box panel stretch factor for the given widget.
*
* @param widget - The widget of interest.
*
* @returns The box panel stretch factor for the widget.
*/
export function getStretch(widget: Widget): number {
return BoxLayout.getStretch(widget);
}
/**
* Set the box panel stretch factor for the given widget.
*
* @param widget - The widget of interest.
*
* @param value - The value for the stretch factor.
*/
export function setStretch(widget: Widget, value: number): void {
BoxLayout.setStretch(widget, value);
}
/**
* Get the box panel size basis for the given widget.
*
* @param widget - The widget of interest.
*
* @returns The box panel size basis for the widget.
*/
export function getSizeBasis(widget: Widget): number {
return BoxLayout.getSizeBasis(widget);
}
/**
* Set the box panel size basis for the given widget.
*
* @param widget - The widget of interest.
*
* @param value - The value for the size basis.
*/
export function setSizeBasis(widget: Widget, value: number): void {
BoxLayout.setSizeBasis(widget, value);
}
}
/**
* The namespace for the module implementation details.
*/
namespace Private {
/**
* Create a box layout for the given panel options.
*/
export function createLayout(options: BoxPanel.IOptions): BoxLayout {
return options.layout || new BoxLayout(options);
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,390 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// Copyright (c) Jupyter Development Team.
// Distributed under the terms of the Modified BSD License.
/*-----------------------------------------------------------------------------
| Copyright (c) 2014-2017, PhosphorJS Contributors
|
| Distributed under the terms of the BSD 3-Clause License.
|
| The full license is in the file LICENSE, distributed with this software.
|----------------------------------------------------------------------------*/
import { type ISignal, Signal } from '../signaling';
import { type IDisposable } from '../disposable';
import { ArrayExt, find, max } from '../algorithm';
import { type Widget } from './widget';
/**
* A class which tracks focus among a set of widgets.
*
* This class is useful when code needs to keep track of the most
* recently focused widget(s) among a set of related widgets.
*/
export class FocusTracker<T extends Widget> implements IDisposable {
/**
* Dispose of the resources held by the tracker.
*/
dispose(): void {
// Do nothing if the tracker is already disposed.
if (this._counter < 0) {
return;
}
// Mark the tracker as disposed.
this._counter = -1;
// Clear the connections for the tracker.
Signal.clearData(this);
// Remove all event listeners.
for (const widget of this._widgets) {
widget.node.removeEventListener('focus', this, true);
widget.node.removeEventListener('blur', this, true);
}
// Clear the internal data structures.
this._activeWidget = null;
this._currentWidget = null;
this._nodes.clear();
this._numbers.clear();
this._widgets.length = 0;
}
/**
* A signal emitted when the current widget has changed.
*/
get currentChanged(): ISignal<this, FocusTracker.IChangedArgs<T>> {
return this._currentChanged;
}
/**
* A signal emitted when the active widget has changed.
*/
get activeChanged(): ISignal<this, FocusTracker.IChangedArgs<T>> {
return this._activeChanged;
}
/**
* A flag indicating whether the tracker is disposed.
*/
get isDisposed(): boolean {
return this._counter < 0;
}
/**
* The current widget in the tracker.
*
* #### Notes
* The current widget is the widget among the tracked widgets which
* has the *descendant node* which has most recently been focused.
*
* The current widget will not be updated if the node loses focus. It
* will only be updated when a different tracked widget gains focus.
*
* If the current widget is removed from the tracker, the previous
* current widget will be restored.
*
* This behavior is intended to follow a user's conceptual model of
* a semantically "current" widget, where the "last thing of type X"
* to be interacted with is the "current instance of X", regardless
* of whether that instance still has focus.
*/
get currentWidget(): T | null {
return this._currentWidget;
}
/**
* The active widget in the tracker.
*
* #### Notes
* The active widget is the widget among the tracked widgets which
* has the *descendant node* which is currently focused.
*/
get activeWidget(): T | null {
return this._activeWidget;
}
/**
* A read only array of the widgets being tracked.
*/
get widgets(): ReadonlyArray<T> {
return this._widgets;
}
/**
* Get the focus number for a particular widget in the tracker.
*
* @param widget - The widget of interest.
*
* @returns The focus number for the given widget, or `-1` if the
* widget has not had focus since being added to the tracker, or
* is not contained by the tracker.
*
* #### Notes
* The focus number indicates the relative order in which the widgets
* have gained focus. A widget with a larger number has gained focus
* more recently than a widget with a smaller number.
*
* The `currentWidget` will always have the largest focus number.
*
* All widgets start with a focus number of `-1`, which indicates that
* the widget has not been focused since being added to the tracker.
*/
focusNumber(widget: T): number {
const n = this._numbers.get(widget);
return n === undefined ? -1 : n;
}
/**
* Test whether the focus tracker contains a given widget.
*
* @param widget - The widget of interest.
*
* @returns `true` if the widget is tracked, `false` otherwise.
*/
has(widget: T): boolean {
return this._numbers.has(widget);
}
/**
* Add a widget to the focus tracker.
*
* @param widget - The widget of interest.
*
* #### Notes
* A widget will be automatically removed from the tracker if it
* is disposed after being added.
*
* If the widget is already tracked, this is a no-op.
*/
add(widget: T): void {
// Do nothing if the widget is already tracked.
if (this._numbers.has(widget)) {
return;
}
// Test whether the widget has focus.
const focused = widget.node.contains(document.activeElement);
// Set up the initial focus number.
const n = focused ? this._counter++ : -1;
// Add the widget to the internal data structures.
this._widgets.push(widget);
this._numbers.set(widget, n);
this._nodes.set(widget.node, widget);
// Set up the event listeners. The capturing phase must be used
// since the 'focus' and 'blur' events don't bubble and Firefox
// doesn't support the 'focusin' or 'focusout' events.
widget.node.addEventListener('focus', this, true);
widget.node.addEventListener('blur', this, true);
// Connect the disposed signal handler.
widget.disposed.connect(this._onWidgetDisposed, this);
// Set the current and active widgets if needed.
if (focused) {
this._setWidgets(widget, widget);
}
}
/**
* Remove a widget from the focus tracker.
*
* #### Notes
* If the widget is the `currentWidget`, the previous current widget
* will become the new `currentWidget`.
*
* A widget will be automatically removed from the tracker if it
* is disposed after being added.
*
* If the widget is not tracked, this is a no-op.
*/
remove(widget: T): void {
// Bail early if the widget is not tracked.
if (!this._numbers.has(widget)) {
return;
}
// Disconnect the disposed signal handler.
widget.disposed.disconnect(this._onWidgetDisposed, this);
// Remove the event listeners.
widget.node.removeEventListener('focus', this, true);
widget.node.removeEventListener('blur', this, true);
// Remove the widget from the internal data structures.
ArrayExt.removeFirstOf(this._widgets, widget);
this._nodes.delete(widget.node);
this._numbers.delete(widget);
// Bail early if the widget is not the current widget.
if (this._currentWidget !== widget) {
return;
}
// Filter the widgets for those which have had focus.
const valid = this._widgets.filter(w => this._numbers.get(w) !== -1);
// Get the valid widget with the max focus number.
const previous =
max(valid, (first, second) => {
const a = this._numbers.get(first)!;
const b = this._numbers.get(second)!;
return a - b;
}) || null;
// Set the current and active widgets.
this._setWidgets(previous, null);
}
/**
* Handle the DOM events for the focus tracker.
*
* @param event - The DOM event sent to the panel.
*
* #### Notes
* This method implements the DOM `EventListener` interface and is
* called in response to events on the tracked nodes. It should
* not be called directly by user code.
*/
handleEvent(event: Event): void {
switch (event.type) {
case 'focus':
this._evtFocus(event as FocusEvent);
break;
case 'blur':
this._evtBlur(event as FocusEvent);
break;
default:
break;
}
}
/**
* Set the current and active widgets for the tracker.
*/
private _setWidgets(current: T | null, active: T | null): void {
// Swap the current widget.
const oldCurrent = this._currentWidget;
this._currentWidget = current;
// Swap the active widget.
const oldActive = this._activeWidget;
this._activeWidget = active;
// Emit the `currentChanged` signal if needed.
if (oldCurrent !== current) {
this._currentChanged.emit({ oldValue: oldCurrent, newValue: current });
}
// Emit the `activeChanged` signal if needed.
if (oldActive !== active) {
this._activeChanged.emit({ oldValue: oldActive, newValue: active });
}
}
/**
* Handle the `'focus'` event for a tracked widget.
*/
private _evtFocus(event: FocusEvent): void {
// Find the widget which gained focus, which is known to exist.
const widget = this._nodes.get(event.currentTarget as HTMLElement)!;
// Update the focus number if necessary.
if (widget !== this._currentWidget) {
this._numbers.set(widget, this._counter++);
}
// Set the current and active widgets.
this._setWidgets(widget, widget);
}
/**
* Handle the `'blur'` event for a tracked widget.
*/
private _evtBlur(event: FocusEvent): void {
// Find the widget which lost focus, which is known to exist.
const widget = this._nodes.get(event.currentTarget as HTMLElement)!;
// Get the node which being focused after this blur.
const focusTarget = event.relatedTarget as HTMLElement;
// If no other node is being focused, clear the active widget.
if (!focusTarget) {
this._setWidgets(this._currentWidget, null);
return;
}
// Bail if the focus widget is not changing.
if (widget.node.contains(focusTarget)) {
return;
}
// If no tracked widget is being focused, clear the active widget.
if (!find(this._widgets, w => w.node.contains(focusTarget))) {
this._setWidgets(this._currentWidget, null);
return;
}
}
/**
* Handle the `disposed` signal for a tracked widget.
*/
private _onWidgetDisposed(sender: T): void {
this.remove(sender);
}
private _counter = 0;
private _widgets: T[] = [];
private _activeWidget: T | null = null;
private _currentWidget: T | null = null;
private _numbers = new Map<T, number>();
private _nodes = new Map<HTMLElement, T>();
private _activeChanged = new Signal<this, FocusTracker.IChangedArgs<T>>(this);
private _currentChanged = new Signal<this, FocusTracker.IChangedArgs<T>>(
this,
);
}
/**
* The namespace for the `FocusTracker` class statics.
*/
export namespace FocusTracker {
/**
* An arguments object for the changed signals.
*/
export interface IChangedArgs<T extends Widget> {
/**
* The old value for the widget.
*/
oldValue: T | null;
/**
* The new value for the widget.
*/
newValue: T | null;
}
}

View File

@@ -0,0 +1,890 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// Copyright (c) Jupyter Development Team.
// Distributed under the terms of the Modified BSD License.
/*-----------------------------------------------------------------------------
| Copyright (c) 2014-2017, PhosphorJS Contributors
|
| Distributed under the terms of the BSD 3-Clause License.
|
| The full license is in the file LICENSE, distributed with this software.
|----------------------------------------------------------------------------*/
import { AttachedProperty } from '../properties';
import { type Message, MessageLoop } from '../messaging';
import { ElementExt } from '../domutils';
import { ArrayExt } from '../algorithm';
import { Widget } from './widget';
import { Layout, LayoutItem } from './layout';
import { BoxEngine, BoxSizer } from './boxengine';
/**
* A layout which arranges its widgets in a grid.
*/
export class GridLayout extends Layout {
/**
* Construct a new grid layout.
*
* @param options - The options for initializing the layout.
*/
constructor(options: GridLayout.IOptions = {}) {
super(options);
if (options.rowCount !== undefined) {
Private.reallocSizers(this._rowSizers, options.rowCount);
}
if (options.columnCount !== undefined) {
Private.reallocSizers(this._columnSizers, options.columnCount);
}
if (options.rowSpacing !== undefined) {
this._rowSpacing = Private.clampValue(options.rowSpacing);
}
if (options.columnSpacing !== undefined) {
this._columnSpacing = Private.clampValue(options.columnSpacing);
}
}
/**
* Dispose of the resources held by the layout.
*/
dispose(): void {
// Dispose of the widgets and layout items.
for (const item of this._items) {
const { widget } = item;
item.dispose();
widget.dispose();
}
// Clear the layout state.
this._box = null;
this._items.length = 0;
this._rowStarts.length = 0;
this._rowSizers.length = 0;
this._columnStarts.length = 0;
this._columnSizers.length = 0;
// Dispose of the rest of the layout.
super.dispose();
}
/**
* Get the number of rows in the layout.
*/
get rowCount(): number {
return this._rowSizers.length;
}
/**
* Set the number of rows in the layout.
*
* #### Notes
* The minimum row count is `1`.
*/
set rowCount(value: number) {
// Do nothing if the row count does not change.
if (value === this.rowCount) {
return;
}
// Reallocate the row sizers.
Private.reallocSizers(this._rowSizers, value);
// Schedule a fit of the parent.
if (this.parent) {
this.parent.fit();
}
}
/**
* Get the number of columns in the layout.
*/
get columnCount(): number {
return this._columnSizers.length;
}
/**
* Set the number of columns in the layout.
*
* #### Notes
* The minimum column count is `1`.
*/
set columnCount(value: number) {
// Do nothing if the column count does not change.
if (value === this.columnCount) {
return;
}
// Reallocate the column sizers.
Private.reallocSizers(this._columnSizers, value);
// Schedule a fit of the parent.
if (this.parent) {
this.parent.fit();
}
}
/**
* Get the row spacing for the layout.
*/
get rowSpacing(): number {
return this._rowSpacing;
}
/**
* Set the row spacing for the layout.
*/
set rowSpacing(value: number) {
// Clamp the spacing to the allowed range.
value = Private.clampValue(value);
// Bail if the spacing does not change
if (this._rowSpacing === value) {
return;
}
// Update the internal spacing.
this._rowSpacing = value;
// Schedule a fit of the parent.
if (this.parent) {
this.parent.fit();
}
}
/**
* Get the column spacing for the layout.
*/
get columnSpacing(): number {
return this._columnSpacing;
}
/**
* Set the col spacing for the layout.
*/
set columnSpacing(value: number) {
// Clamp the spacing to the allowed range.
value = Private.clampValue(value);
// Bail if the spacing does not change
if (this._columnSpacing === value) {
return;
}
// Update the internal spacing.
this._columnSpacing = value;
// Schedule a fit of the parent.
if (this.parent) {
this.parent.fit();
}
}
/**
* Get the stretch factor for a specific row.
*
* @param index - The row index of interest.
*
* @returns The stretch factor for the row.
*
* #### Notes
* This returns `-1` if the index is out of range.
*/
rowStretch(index: number): number {
const sizer = this._rowSizers[index];
return sizer ? sizer.stretch : -1;
}
/**
* Set the stretch factor for a specific row.
*
* @param index - The row index of interest.
*
* @param value - The stretch factor for the row.
*
* #### Notes
* This is a no-op if the index is out of range.
*/
setRowStretch(index: number, value: number): void {
// Look up the row sizer.
const sizer = this._rowSizers[index];
// Bail if the index is out of range.
if (!sizer) {
return;
}
// Clamp the value to the allowed range.
value = Private.clampValue(value);
// Bail if the stretch does not change.
if (sizer.stretch === value) {
return;
}
// Update the sizer stretch.
sizer.stretch = value;
// Schedule an update of the parent.
if (this.parent) {
this.parent.update();
}
}
/**
* Get the stretch factor for a specific column.
*
* @param index - The column index of interest.
*
* @returns The stretch factor for the column.
*
* #### Notes
* This returns `-1` if the index is out of range.
*/
columnStretch(index: number): number {
const sizer = this._columnSizers[index];
return sizer ? sizer.stretch : -1;
}
/**
* Set the stretch factor for a specific column.
*
* @param index - The column index of interest.
*
* @param value - The stretch factor for the column.
*
* #### Notes
* This is a no-op if the index is out of range.
*/
setColumnStretch(index: number, value: number): void {
// Look up the column sizer.
const sizer = this._columnSizers[index];
// Bail if the index is out of range.
if (!sizer) {
return;
}
// Clamp the value to the allowed range.
value = Private.clampValue(value);
// Bail if the stretch does not change.
if (sizer.stretch === value) {
return;
}
// Update the sizer stretch.
sizer.stretch = value;
// Schedule an update of the parent.
if (this.parent) {
this.parent.update();
}
}
/**
* Create an iterator over the widgets in the layout.
*
* @returns A new iterator over the widgets in the layout.
*/
*[Symbol.iterator](): IterableIterator<Widget> {
for (const item of this._items) {
yield item.widget;
}
}
/**
* Add a widget to the grid layout.
*
* @param widget - The widget to add to the layout.
*
* #### Notes
* If the widget is already contained in the layout, this is no-op.
*/
addWidget(widget: Widget): void {
// Look up the index for the widget.
const i = ArrayExt.findFirstIndex(this._items, it => it.widget === widget);
// Bail if the widget is already in the layout.
if (i !== -1) {
return;
}
// Add the widget to the layout.
this._items.push(new LayoutItem(widget));
// Attach the widget to the parent.
if (this.parent) {
this.attachWidget(widget);
}
}
/**
* Remove a widget from the grid layout.
*
* @param widget - The widget to remove from the layout.
*
* #### Notes
* A widget is automatically removed from the layout when its `parent`
* is set to `null`. This method should only be invoked directly when
* removing a widget from a layout which has yet to be installed on a
* parent widget.
*
* This method does *not* modify the widget's `parent`.
*/
removeWidget(widget: Widget): void {
// Look up the index for the widget.
const i = ArrayExt.findFirstIndex(this._items, it => it.widget === widget);
// Bail if the widget is not in the layout.
if (i === -1) {
return;
}
// Remove the widget from the layout.
const item = ArrayExt.removeAt(this._items, i)!;
// Detach the widget from the parent.
if (this.parent) {
this.detachWidget(widget);
}
// Dispose the layout item.
item.dispose();
}
/**
* Perform layout initialization which requires the parent widget.
*/
protected init(): void {
super.init();
for (const widget of this) {
this.attachWidget(widget);
}
}
/**
* Attach a widget to the parent's DOM node.
*
* @param widget - The widget to attach to the parent.
*/
protected attachWidget(widget: Widget): void {
// Send a `'before-attach'` message if the parent is attached.
if (this.parent!.isAttached) {
MessageLoop.sendMessage(widget, Widget.Msg.BeforeAttach);
}
// Add the widget's node to the parent.
this.parent!.node.appendChild(widget.node);
// Send an `'after-attach'` message if the parent is attached.
if (this.parent!.isAttached) {
MessageLoop.sendMessage(widget, Widget.Msg.AfterAttach);
}
// Post a fit request for the parent widget.
this.parent!.fit();
}
/**
* Detach a widget from the parent's DOM node.
*
* @param widget - The widget to detach from the parent.
*/
protected detachWidget(widget: Widget): void {
// Send a `'before-detach'` message if the parent is attached.
if (this.parent!.isAttached) {
MessageLoop.sendMessage(widget, Widget.Msg.BeforeDetach);
}
// Remove the widget's node from the parent.
this.parent!.node.removeChild(widget.node);
// Send an `'after-detach'` message if the parent is attached.
if (this.parent!.isAttached) {
MessageLoop.sendMessage(widget, Widget.Msg.AfterDetach);
}
// Post a fit request for the parent widget.
this.parent!.fit();
}
/**
* A message handler invoked on a `'before-show'` message.
*/
protected onBeforeShow(msg: Message): void {
super.onBeforeShow(msg);
this.parent!.update();
}
/**
* A message handler invoked on a `'before-attach'` message.
*/
protected onBeforeAttach(msg: Message): void {
super.onBeforeAttach(msg);
this.parent!.fit();
}
/**
* A message handler invoked on a `'child-shown'` message.
*/
protected onChildShown(msg: Widget.ChildMessage): void {
this.parent!.fit();
}
/**
* A message handler invoked on a `'child-hidden'` message.
*/
protected onChildHidden(msg: Widget.ChildMessage): void {
this.parent!.fit();
}
/**
* A message handler invoked on a `'resize'` message.
*/
protected onResize(msg: Widget.ResizeMessage): void {
if (this.parent!.isVisible) {
this._update(msg.width, msg.height);
}
}
/**
* A message handler invoked on an `'update-request'` message.
*/
protected onUpdateRequest(msg: Message): void {
if (this.parent!.isVisible) {
this._update(-1, -1);
}
}
/**
* A message handler invoked on a `'fit-request'` message.
*/
protected onFitRequest(msg: Message): void {
if (this.parent!.isAttached) {
this._fit();
}
}
/**
* Fit the layout to the total size required by the widgets.
*/
private _fit(): void {
// Reset the min sizes of the sizers.
for (let i = 0, n = this.rowCount; i < n; ++i) {
this._rowSizers[i].minSize = 0;
}
for (let i = 0, n = this.columnCount; i < n; ++i) {
this._columnSizers[i].minSize = 0;
}
// Filter for the visible layout items.
const items = this._items.filter(it => !it.isHidden);
// Fit the layout items.
for (let i = 0, n = items.length; i < n; ++i) {
items[i].fit();
}
// Get the max row and column index.
const maxRow = this.rowCount - 1;
const maxCol = this.columnCount - 1;
// Sort the items by row span.
items.sort(Private.rowSpanCmp);
// Update the min sizes of the row sizers.
for (let i = 0, n = items.length; i < n; ++i) {
// Fetch the item.
const item = items[i];
// Get the row bounds for the item.
const config = GridLayout.getCellConfig(item.widget);
const r1 = Math.min(config.row, maxRow);
const r2 = Math.min(config.row + config.rowSpan - 1, maxRow);
// Distribute the minimum height to the sizers as needed.
Private.distributeMin(this._rowSizers, r1, r2, item.minHeight);
}
// Sort the items by column span.
items.sort(Private.columnSpanCmp);
// Update the min sizes of the column sizers.
for (let i = 0, n = items.length; i < n; ++i) {
// Fetch the item.
const item = items[i];
// Get the column bounds for the item.
const config = GridLayout.getCellConfig(item.widget);
const c1 = Math.min(config.column, maxCol);
const c2 = Math.min(config.column + config.columnSpan - 1, maxCol);
// Distribute the minimum width to the sizers as needed.
Private.distributeMin(this._columnSizers, c1, c2, item.minWidth);
}
// If no size constraint is needed, just update the parent.
if (this.fitPolicy === 'set-no-constraint') {
MessageLoop.sendMessage(this.parent!, Widget.Msg.UpdateRequest);
return;
}
// Set up the computed min size.
let minH = maxRow * this._rowSpacing;
let minW = maxCol * this._columnSpacing;
// Add the sizer minimums to the computed min size.
for (let i = 0, n = this.rowCount; i < n; ++i) {
minH += this._rowSizers[i].minSize;
}
for (let i = 0, n = this.columnCount; i < n; ++i) {
minW += this._columnSizers[i].minSize;
}
// Update the box sizing and add it to the computed min size.
const box = (this._box = ElementExt.boxSizing(this.parent!.node));
minW += box.horizontalSum;
minH += box.verticalSum;
// Update the parent's min size constraints.
const { style } = this.parent!.node;
style.minWidth = `${minW}px`;
style.minHeight = `${minH}px`;
// Set the dirty flag to ensure only a single update occurs.
this._dirty = true;
// Notify the ancestor that it should fit immediately. This may
// cause a resize of the parent, fulfilling the required update.
if (this.parent!.parent) {
MessageLoop.sendMessage(this.parent!.parent!, Widget.Msg.FitRequest);
}
// If the dirty flag is still set, the parent was not resized.
// Trigger the required update on the parent widget immediately.
if (this._dirty) {
MessageLoop.sendMessage(this.parent!, Widget.Msg.UpdateRequest);
}
}
/**
* Update the layout position and size of the widgets.
*
* The parent offset dimensions should be `-1` if unknown.
*/
private _update(offsetWidth: number, offsetHeight: number): void {
// Clear the dirty flag to indicate the update occurred.
this._dirty = false;
// Measure the parent if the offset dimensions are unknown.
if (offsetWidth < 0) {
offsetWidth = this.parent!.node.offsetWidth;
}
if (offsetHeight < 0) {
offsetHeight = this.parent!.node.offsetHeight;
}
// Ensure the parent box sizing data is computed.
if (!this._box) {
this._box = ElementExt.boxSizing(this.parent!.node);
}
// Compute the layout area adjusted for border and padding.
const top = this._box.paddingTop;
const left = this._box.paddingLeft;
const width = offsetWidth - this._box.horizontalSum;
const height = offsetHeight - this._box.verticalSum;
// Get the max row and column index.
const maxRow = this.rowCount - 1;
const maxCol = this.columnCount - 1;
// Compute the total fixed row and column space.
const fixedRowSpace = maxRow * this._rowSpacing;
const fixedColSpace = maxCol * this._columnSpacing;
// Distribute the available space to the box sizers.
BoxEngine.calc(this._rowSizers, Math.max(0, height - fixedRowSpace));
BoxEngine.calc(this._columnSizers, Math.max(0, width - fixedColSpace));
// Update the row start positions.
for (let i = 0, pos = top, n = this.rowCount; i < n; ++i) {
this._rowStarts[i] = pos;
pos += this._rowSizers[i].size + this._rowSpacing;
}
// Update the column start positions.
for (let i = 0, pos = left, n = this.columnCount; i < n; ++i) {
this._columnStarts[i] = pos;
pos += this._columnSizers[i].size + this._columnSpacing;
}
// Update the geometry of the layout items.
for (let i = 0, n = this._items.length; i < n; ++i) {
// Fetch the item.
const item = this._items[i];
// Ignore hidden items.
if (item.isHidden) {
continue;
}
// Fetch the cell bounds for the widget.
const config = GridLayout.getCellConfig(item.widget);
const r1 = Math.min(config.row, maxRow);
const c1 = Math.min(config.column, maxCol);
const r2 = Math.min(config.row + config.rowSpan - 1, maxRow);
const c2 = Math.min(config.column + config.columnSpan - 1, maxCol);
// Compute the cell geometry.
const x = this._columnStarts[c1];
const y = this._rowStarts[r1];
const w = this._columnStarts[c2] + this._columnSizers[c2].size - x;
const h = this._rowStarts[r2] + this._rowSizers[r2].size - y;
// Update the geometry of the layout item.
item.update(x, y, w, h);
}
}
private _dirty = false;
private _rowSpacing = 4;
private _columnSpacing = 4;
private _items: LayoutItem[] = [];
private _rowStarts: number[] = [];
private _columnStarts: number[] = [];
private _rowSizers: BoxSizer[] = [new BoxSizer()];
private _columnSizers: BoxSizer[] = [new BoxSizer()];
private _box: ElementExt.IBoxSizing | null = null;
}
/**
* The namespace for the `GridLayout` class statics.
*/
export namespace GridLayout {
/**
* An options object for initializing a grid layout.
*/
export interface IOptions extends Layout.IOptions {
/**
* The initial row count for the layout.
*
* The default is `1`.
*/
rowCount?: number;
/**
* The initial column count for the layout.
*
* The default is `1`.
*/
columnCount?: number;
/**
* The spacing between rows in the layout.
*
* The default is `4`.
*/
rowSpacing?: number;
/**
* The spacing between columns in the layout.
*
* The default is `4`.
*/
columnSpacing?: number;
}
/**
* An object which holds the cell configuration for a widget.
*/
export interface ICellConfig {
/**
* The row index for the widget.
*/
readonly row: number;
/**
* The column index for the widget.
*/
readonly column: number;
/**
* The row span for the widget.
*/
readonly rowSpan: number;
/**
* The column span for the widget.
*/
readonly columnSpan: number;
}
/**
* Get the cell config for the given widget.
*
* @param widget - The widget of interest.
*
* @returns The cell config for the widget.
*/
export function getCellConfig(widget: Widget): ICellConfig {
return Private.cellConfigProperty.get(widget);
}
/**
* Set the cell config for the given widget.
*
* @param widget - The widget of interest.
*
* @param value - The value for the cell config.
*/
export function setCellConfig(
widget: Widget,
value: Partial<ICellConfig>,
): void {
Private.cellConfigProperty.set(widget, Private.normalizeConfig(value));
}
}
/**
* The namespace for the module implementation details.
*/
namespace Private {
/**
* The property descriptor for the widget cell config.
*/
export const cellConfigProperty = new AttachedProperty<
Widget,
GridLayout.ICellConfig
>({
name: 'cellConfig',
create: () => ({ row: 0, column: 0, rowSpan: 1, columnSpan: 1 }),
changed: onChildCellConfigChanged,
});
/**
* Normalize a partial cell config object.
*/
export function normalizeConfig(
config: Partial<GridLayout.ICellConfig>,
): GridLayout.ICellConfig {
const row = Math.max(0, Math.floor(config.row || 0));
const column = Math.max(0, Math.floor(config.column || 0));
const rowSpan = Math.max(1, Math.floor(config.rowSpan || 0));
const columnSpan = Math.max(1, Math.floor(config.columnSpan || 0));
return { row, column, rowSpan, columnSpan };
}
/**
* Clamp a value to an integer >= 0.
*/
export function clampValue(value: number): number {
return Math.max(0, Math.floor(value));
}
/**
* A sort comparison function for row spans.
*/
export function rowSpanCmp(a: LayoutItem, b: LayoutItem): number {
const c1 = cellConfigProperty.get(a.widget);
const c2 = cellConfigProperty.get(b.widget);
return c1.rowSpan - c2.rowSpan;
}
/**
* A sort comparison function for column spans.
*/
export function columnSpanCmp(a: LayoutItem, b: LayoutItem): number {
const c1 = cellConfigProperty.get(a.widget);
const c2 = cellConfigProperty.get(b.widget);
return c1.columnSpan - c2.columnSpan;
}
/**
* Reallocate the box sizers for the given grid dimensions.
*/
export function reallocSizers(sizers: BoxSizer[], count: number): void {
// Coerce the count to the valid range.
count = Math.max(1, Math.floor(count));
// Add the missing sizers.
while (sizers.length < count) {
sizers.push(new BoxSizer());
}
// Remove the extra sizers.
if (sizers.length > count) {
sizers.length = count;
}
}
/**
* Distribute a min size constraint across a range of sizers.
*/
export function distributeMin(
sizers: BoxSizer[],
i1: number,
i2: number,
minSize: number,
): void {
// Sanity check the indices.
if (i2 < i1) {
return;
}
// Handle the simple case of no cell span.
if (i1 === i2) {
const sizer = sizers[i1];
sizer.minSize = Math.max(sizer.minSize, minSize);
return;
}
// Compute the total current min size of the span.
let totalMin = 0;
for (let i = i1; i <= i2; ++i) {
totalMin += sizers[i].minSize;
}
// Do nothing if the total is greater than the required.
if (totalMin >= minSize) {
return;
}
// Compute the portion of the space to allocate to each sizer.
const portion = (minSize - totalMin) / (i2 - i1 + 1);
// Add the portion to each sizer.
for (let i = i1; i <= i2; ++i) {
sizers[i].minSize += portion;
}
}
/**
* The change handler for the child cell config property.
*/
function onChildCellConfigChanged(child: Widget): void {
if (child.parent && child.parent.layout instanceof GridLayout) {
child.parent.fit();
}
}
}

View File

@@ -0,0 +1,49 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// Copyright (c) Jupyter Development Team.
// Distributed under the terms of the Modified BSD License.
/*-----------------------------------------------------------------------------
| Copyright (c) 2014-2017, PhosphorJS Contributors
|
| Distributed under the terms of the BSD 3-Clause License.
|
| The full license is in the file LICENSE, distributed with this software.
|----------------------------------------------------------------------------*/
/**
* @packageDocumentation
* @module widgets
*/
export * from './boxengine';
export * from './boxlayout';
export * from './boxpanel';
export * from './docklayout';
export * from './dockpanel';
export * from './focustracker';
export * from './gridlayout';
export * from './layout';
export * from './panel';
export * from './panellayout';
export * from './scrollbar';
export * from './singletonlayout';
export * from './splitlayout';
export * from './splitpanel';
export * from './stackedlayout';
export * from './stackedpanel';
export * from './tabbar';
export * from './tabpanel';
export * from './title';
export * from './widget';

View File

@@ -0,0 +1,891 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// Copyright (c) Jupyter Development Team.
// Distributed under the terms of the Modified BSD License.
/*-----------------------------------------------------------------------------
| Copyright (c) 2014-2017, PhosphorJS Contributors
|
| Distributed under the terms of the BSD 3-Clause License.
|
| The full license is in the file LICENSE, distributed with this software.
|----------------------------------------------------------------------------*/
import { Signal } from '../signaling';
import { AttachedProperty } from '../properties';
import { type Message, MessageLoop } from '../messaging';
import { ElementExt } from '../domutils';
import { type IDisposable } from '../disposable';
import { Widget } from './widget';
/**
* An abstract base class for creating lumino layouts.
*
* #### Notes
* A layout is used to add widgets to a parent and to arrange those
* widgets within the parent's DOM node.
*
* This class implements the base functionality which is required of
* nearly all layouts. It must be subclassed in order to be useful.
*
* Notably, this class does not define a uniform interface for adding
* widgets to the layout. A subclass should define that API in a way
* which is meaningful for its intended use.
*/
export abstract class Layout implements Iterable<Widget>, IDisposable {
/**
* Construct a new layout.
*
* @param options - The options for initializing the layout.
*/
constructor(options: Layout.IOptions = {}) {
this._fitPolicy = options.fitPolicy || 'set-min-size';
}
/**
* Dispose of the resources held by the layout.
*
* #### Notes
* This should be reimplemented to clear and dispose of the widgets.
*
* All reimplementations should call the superclass method.
*
* This method is called automatically when the parent is disposed.
*/
dispose(): void {
this._parent = null;
this._disposed = true;
Signal.clearData(this);
AttachedProperty.clearData(this);
}
/**
* Test whether the layout is disposed.
*/
get isDisposed(): boolean {
return this._disposed;
}
/**
* Get the parent widget of the layout.
*/
get parent(): Widget | null {
return this._parent;
}
/**
* Set the parent widget of the layout.
*
* #### Notes
* This is set automatically when installing the layout on the parent
* widget. The parent widget should not be set directly by user code.
*/
set parent(value: Widget | null) {
if (this._parent === value) {
return;
}
if (this._parent) {
throw new Error('Cannot change parent widget.');
}
if (value!.layout !== this) {
throw new Error('Invalid parent widget.');
}
this._parent = value;
this.init();
}
/**
* Get the fit policy for the layout.
*
* #### Notes
* The fit policy controls the computed size constraints which are
* applied to the parent widget by the layout.
*
* Some layout implementations may ignore the fit policy.
*/
get fitPolicy(): Layout.FitPolicy {
return this._fitPolicy;
}
/**
* Set the fit policy for the layout.
*
* #### Notes
* The fit policy controls the computed size constraints which are
* applied to the parent widget by the layout.
*
* Some layout implementations may ignore the fit policy.
*
* Changing the fit policy will clear the current size constraint
* for the parent widget and then re-fit the parent.
*/
set fitPolicy(value: Layout.FitPolicy) {
// Bail if the policy does not change
if (this._fitPolicy === value) {
return;
}
// Update the internal policy.
this._fitPolicy = value;
// Clear the size constraints and schedule a fit of the parent.
if (this._parent) {
const { style } = this._parent.node;
style.minWidth = '';
style.minHeight = '';
style.maxWidth = '';
style.maxHeight = '';
this._parent.fit();
}
}
/**
* Create an iterator over the widgets in the layout.
*
* @returns A new iterator over the widgets in the layout.
*
* #### Notes
* This abstract method must be implemented by a subclass.
*/
abstract [Symbol.iterator](): IterableIterator<Widget>;
/**
* Remove a widget from the layout.
*
* @param widget - The widget to remove from the layout.
*
* #### Notes
* A widget is automatically removed from the layout when its `parent`
* is set to `null`. This method should only be invoked directly when
* removing a widget from a layout which has yet to be installed on a
* parent widget.
*
* This method should *not* modify the widget's `parent`.
*/
abstract removeWidget(widget: Widget): void;
/**
* Process a message sent to the parent widget.
*
* @param msg - The message sent to the parent widget.
*
* #### Notes
* This method is called by the parent widget to process a message.
*
* Subclasses may reimplement this method as needed.
*/
processParentMessage(msg: Message): void {
switch (msg.type) {
case 'resize':
this.onResize(msg as Widget.ResizeMessage);
break;
case 'update-request':
this.onUpdateRequest(msg);
break;
case 'fit-request':
this.onFitRequest(msg);
break;
case 'before-show':
this.onBeforeShow(msg);
break;
case 'after-show':
this.onAfterShow(msg);
break;
case 'before-hide':
this.onBeforeHide(msg);
break;
case 'after-hide':
this.onAfterHide(msg);
break;
case 'before-attach':
this.onBeforeAttach(msg);
break;
case 'after-attach':
this.onAfterAttach(msg);
break;
case 'before-detach':
this.onBeforeDetach(msg);
break;
case 'after-detach':
this.onAfterDetach(msg);
break;
case 'child-removed':
this.onChildRemoved(msg as Widget.ChildMessage);
break;
case 'child-shown':
this.onChildShown(msg as Widget.ChildMessage);
break;
case 'child-hidden':
this.onChildHidden(msg as Widget.ChildMessage);
break;
}
}
/**
* Perform layout initialization which requires the parent widget.
*
* #### Notes
* This method is invoked immediately after the layout is installed
* on the parent widget.
*
* The default implementation reparents all of the widgets to the
* layout parent widget.
*
* Subclasses should reimplement this method and attach the child
* widget nodes to the parent widget's node.
*/
protected init(): void {
for (const widget of this) {
widget.parent = this.parent;
}
}
/**
* A message handler invoked on a `'resize'` message.
*
* #### Notes
* The layout should ensure that its widgets are resized according
* to the specified layout space, and that they are sent a `'resize'`
* message if appropriate.
*
* The default implementation of this method sends an `UnknownSize`
* resize message to all widgets.
*
* This may be reimplemented by subclasses as needed.
*/
protected onResize(msg: Widget.ResizeMessage): void {
for (const widget of this) {
MessageLoop.sendMessage(widget, Widget.ResizeMessage.UnknownSize);
}
}
/**
* A message handler invoked on an `'update-request'` message.
*
* #### Notes
* The layout should ensure that its widgets are resized according
* to the available layout space, and that they are sent a `'resize'`
* message if appropriate.
*
* The default implementation of this method sends an `UnknownSize`
* resize message to all widgets.
*
* This may be reimplemented by subclasses as needed.
*/
protected onUpdateRequest(msg: Message): void {
for (const widget of this) {
MessageLoop.sendMessage(widget, Widget.ResizeMessage.UnknownSize);
}
}
/**
* A message handler invoked on a `'before-attach'` message.
*
* #### Notes
* The default implementation of this method forwards the message
* to all widgets. It assumes all widget nodes are attached to the
* parent widget node.
*
* This may be reimplemented by subclasses as needed.
*/
protected onBeforeAttach(msg: Message): void {
for (const widget of this) {
MessageLoop.sendMessage(widget, msg);
}
}
/**
* A message handler invoked on an `'after-attach'` message.
*
* #### Notes
* The default implementation of this method forwards the message
* to all widgets. It assumes all widget nodes are attached to the
* parent widget node.
*
* This may be reimplemented by subclasses as needed.
*/
protected onAfterAttach(msg: Message): void {
for (const widget of this) {
MessageLoop.sendMessage(widget, msg);
}
}
/**
* A message handler invoked on a `'before-detach'` message.
*
* #### Notes
* The default implementation of this method forwards the message
* to all widgets. It assumes all widget nodes are attached to the
* parent widget node.
*
* This may be reimplemented by subclasses as needed.
*/
protected onBeforeDetach(msg: Message): void {
for (const widget of this) {
MessageLoop.sendMessage(widget, msg);
}
}
/**
* A message handler invoked on an `'after-detach'` message.
*
* #### Notes
* The default implementation of this method forwards the message
* to all widgets. It assumes all widget nodes are attached to the
* parent widget node.
*
* This may be reimplemented by subclasses as needed.
*/
protected onAfterDetach(msg: Message): void {
for (const widget of this) {
MessageLoop.sendMessage(widget, msg);
}
}
/**
* A message handler invoked on a `'before-show'` message.
*
* #### Notes
* The default implementation of this method forwards the message to
* all non-hidden widgets. It assumes all widget nodes are attached
* to the parent widget node.
*
* This may be reimplemented by subclasses as needed.
*/
protected onBeforeShow(msg: Message): void {
for (const widget of this) {
if (!widget.isHidden) {
MessageLoop.sendMessage(widget, msg);
}
}
}
/**
* A message handler invoked on an `'after-show'` message.
*
* #### Notes
* The default implementation of this method forwards the message to
* all non-hidden widgets. It assumes all widget nodes are attached
* to the parent widget node.
*
* This may be reimplemented by subclasses as needed.
*/
protected onAfterShow(msg: Message): void {
for (const widget of this) {
if (!widget.isHidden) {
MessageLoop.sendMessage(widget, msg);
}
}
}
/**
* A message handler invoked on a `'before-hide'` message.
*
* #### Notes
* The default implementation of this method forwards the message to
* all non-hidden widgets. It assumes all widget nodes are attached
* to the parent widget node.
*
* This may be reimplemented by subclasses as needed.
*/
protected onBeforeHide(msg: Message): void {
for (const widget of this) {
if (!widget.isHidden) {
MessageLoop.sendMessage(widget, msg);
}
}
}
/**
* A message handler invoked on an `'after-hide'` message.
*
* #### Notes
* The default implementation of this method forwards the message to
* all non-hidden widgets. It assumes all widget nodes are attached
* to the parent widget node.
*
* This may be reimplemented by subclasses as needed.
*/
protected onAfterHide(msg: Message): void {
for (const widget of this) {
if (!widget.isHidden) {
MessageLoop.sendMessage(widget, msg);
}
}
}
/**
* A message handler invoked on a `'child-removed'` message.
*
* #### Notes
* This will remove the child widget from the layout.
*
* Subclasses should **not** typically reimplement this method.
*/
protected onChildRemoved(msg: Widget.ChildMessage): void {
this.removeWidget(msg.child);
}
/**
* A message handler invoked on a `'fit-request'` message.
*
* #### Notes
* The default implementation of this handler is a no-op.
*/
protected onFitRequest(msg: Message): void {}
/**
* A message handler invoked on a `'child-shown'` message.
*
* #### Notes
* The default implementation of this handler is a no-op.
*/
protected onChildShown(msg: Widget.ChildMessage): void {}
/**
* A message handler invoked on a `'child-hidden'` message.
*
* #### Notes
* The default implementation of this handler is a no-op.
*/
protected onChildHidden(msg: Widget.ChildMessage): void {}
private _disposed = false;
private _fitPolicy: Layout.FitPolicy;
private _parent: Widget | null = null;
}
/**
* The namespace for the `Layout` class statics.
*/
export namespace Layout {
/**
* A type alias for the layout fit policy.
*
* #### Notes
* The fit policy controls the computed size constraints which are
* applied to the parent widget by the layout.
*
* Some layout implementations may ignore the fit policy.
*/
export type FitPolicy =
| /**
* No size constraint will be applied to the parent widget.
*/
'set-no-constraint'
/**
* The computed min size will be applied to the parent widget.
*/
| 'set-min-size';
/**
* An options object for initializing a layout.
*/
export interface IOptions {
/**
* The fit policy for the layout.
*
* The default is `'set-min-size'`.
*/
fitPolicy?: FitPolicy;
}
/**
* A type alias for the horizontal alignment of a widget.
*/
export type HorizontalAlignment = 'left' | 'center' | 'right';
/**
* A type alias for the vertical alignment of a widget.
*/
export type VerticalAlignment = 'top' | 'center' | 'bottom';
/**
* Get the horizontal alignment for a widget.
*
* @param widget - The widget of interest.
*
* @returns The horizontal alignment for the widget.
*
* #### Notes
* If the layout width allocated to a widget is larger than its max
* width, the horizontal alignment controls how the widget is placed
* within the extra horizontal space.
*
* If the allocated width is less than the widget's max width, the
* horizontal alignment has no effect.
*
* Some layout implementations may ignore horizontal alignment.
*/
export function getHorizontalAlignment(widget: Widget): HorizontalAlignment {
return Private.horizontalAlignmentProperty.get(widget);
}
/**
* Set the horizontal alignment for a widget.
*
* @param widget - The widget of interest.
*
* @param value - The value for the horizontal alignment.
*
* #### Notes
* If the layout width allocated to a widget is larger than its max
* width, the horizontal alignment controls how the widget is placed
* within the extra horizontal space.
*
* If the allocated width is less than the widget's max width, the
* horizontal alignment has no effect.
*
* Some layout implementations may ignore horizontal alignment.
*
* Changing the horizontal alignment will post an `update-request`
* message to widget's parent, provided the parent has a layout
* installed.
*/
export function setHorizontalAlignment(
widget: Widget,
value: HorizontalAlignment,
): void {
Private.horizontalAlignmentProperty.set(widget, value);
}
/**
* Get the vertical alignment for a widget.
*
* @param widget - The widget of interest.
*
* @returns The vertical alignment for the widget.
*
* #### Notes
* If the layout height allocated to a widget is larger than its max
* height, the vertical alignment controls how the widget is placed
* within the extra vertical space.
*
* If the allocated height is less than the widget's max height, the
* vertical alignment has no effect.
*
* Some layout implementations may ignore vertical alignment.
*/
export function getVerticalAlignment(widget: Widget): VerticalAlignment {
return Private.verticalAlignmentProperty.get(widget);
}
/**
* Set the vertical alignment for a widget.
*
* @param widget - The widget of interest.
*
* @param value - The value for the vertical alignment.
*
* #### Notes
* If the layout height allocated to a widget is larger than its max
* height, the vertical alignment controls how the widget is placed
* within the extra vertical space.
*
* If the allocated height is less than the widget's max height, the
* vertical alignment has no effect.
*
* Some layout implementations may ignore vertical alignment.
*
* Changing the horizontal alignment will post an `update-request`
* message to widget's parent, provided the parent has a layout
* installed.
*/
export function setVerticalAlignment(
widget: Widget,
value: VerticalAlignment,
): void {
Private.verticalAlignmentProperty.set(widget, value);
}
}
/**
* An object which assists in the absolute layout of widgets.
*
* #### Notes
* This class is useful when implementing a layout which arranges its
* widgets using absolute positioning.
*
* This class is used by nearly all of the built-in lumino layouts.
*/
export class LayoutItem implements IDisposable {
/**
* Construct a new layout item.
*
* @param widget - The widget to be managed by the item.
*
* #### Notes
* The widget will be set to absolute positioning.
* The widget will use strict CSS containment.
*/
constructor(widget: Widget) {
this.widget = widget;
this.widget.node.style.position = 'absolute';
this.widget.node.style.contain = 'strict';
}
/**
* Dispose of the the layout item.
*
* #### Notes
* This will reset the positioning of the widget.
*/
dispose(): void {
// Do nothing if the item is already disposed.
if (this._disposed) {
return;
}
// Mark the item as disposed.
this._disposed = true;
// Reset the widget style.
const { style } = this.widget.node;
style.position = '';
style.top = '';
style.left = '';
style.width = '';
style.height = '';
style.contain = '';
}
/**
* The widget managed by the layout item.
*/
readonly widget: Widget;
/**
* The computed minimum width of the widget.
*
* #### Notes
* This value can be updated by calling the `fit` method.
*/
get minWidth(): number {
return this._minWidth;
}
/**
* The computed minimum height of the widget.
*
* #### Notes
* This value can be updated by calling the `fit` method.
*/
get minHeight(): number {
return this._minHeight;
}
/**
* The computed maximum width of the widget.
*
* #### Notes
* This value can be updated by calling the `fit` method.
*/
get maxWidth(): number {
return this._maxWidth;
}
/**
* The computed maximum height of the widget.
*
* #### Notes
* This value can be updated by calling the `fit` method.
*/
get maxHeight(): number {
return this._maxHeight;
}
/**
* Whether the layout item is disposed.
*/
get isDisposed(): boolean {
return this._disposed;
}
/**
* Whether the managed widget is hidden.
*/
get isHidden(): boolean {
return this.widget.isHidden;
}
/**
* Whether the managed widget is visible.
*/
get isVisible(): boolean {
return this.widget.isVisible;
}
/**
* Whether the managed widget is attached.
*/
get isAttached(): boolean {
return this.widget.isAttached;
}
/**
* Update the computed size limits of the managed widget.
*/
fit(): void {
const limits = ElementExt.sizeLimits(this.widget.node);
this._minWidth = limits.minWidth;
this._minHeight = limits.minHeight;
this._maxWidth = limits.maxWidth;
this._maxHeight = limits.maxHeight;
}
/**
* Update the position and size of the managed widget.
*
* @param left - The left edge position of the layout box.
*
* @param top - The top edge position of the layout box.
*
* @param width - The width of the layout box.
*
* @param height - The height of the layout box.
*/
update(left: number, top: number, width: number, height: number): void {
// Clamp the size to the computed size limits.
const clampW = Math.max(this._minWidth, Math.min(width, this._maxWidth));
const clampH = Math.max(this._minHeight, Math.min(height, this._maxHeight));
// Adjust the left edge for the horizontal alignment, if needed.
if (clampW < width) {
switch (Layout.getHorizontalAlignment(this.widget)) {
case 'left':
break;
case 'center':
left += (width - clampW) / 2;
break;
case 'right':
left += width - clampW;
break;
default:
throw 'unreachable';
}
}
// Adjust the top edge for the vertical alignment, if needed.
if (clampH < height) {
switch (Layout.getVerticalAlignment(this.widget)) {
case 'top':
break;
case 'center':
top += (height - clampH) / 2;
break;
case 'bottom':
top += height - clampH;
break;
default:
throw 'unreachable';
}
}
// Set up the resize variables.
let resized = false;
const { style } = this.widget.node;
// Update the top edge of the widget if needed.
if (this._top !== top) {
this._top = top;
style.top = `${top}px`;
}
// Update the left edge of the widget if needed.
if (this._left !== left) {
this._left = left;
style.left = `${left}px`;
}
// Update the width of the widget if needed.
if (this._width !== clampW) {
resized = true;
this._width = clampW;
style.width = `${clampW}px`;
}
// Update the height of the widget if needed.
if (this._height !== clampH) {
resized = true;
this._height = clampH;
style.height = `${clampH}px`;
}
// Send a resize message to the widget if needed.
if (resized) {
const msg = new Widget.ResizeMessage(clampW, clampH);
MessageLoop.sendMessage(this.widget, msg);
}
}
private _top = NaN;
private _left = NaN;
private _width = NaN;
private _height = NaN;
private _minWidth = 0;
private _minHeight = 0;
private _maxWidth = Infinity;
private _maxHeight = Infinity;
private _disposed = false;
}
/**
* The namespace for the module implementation details.
*/
namespace Private {
/**
* The attached property for a widget horizontal alignment.
*/
export const horizontalAlignmentProperty = new AttachedProperty<
Widget,
Layout.HorizontalAlignment
>({
name: 'horizontalAlignment',
create: () => 'center',
changed: onAlignmentChanged,
});
/**
* The attached property for a widget vertical alignment.
*/
export const verticalAlignmentProperty = new AttachedProperty<
Widget,
Layout.VerticalAlignment
>({
name: 'verticalAlignment',
create: () => 'top',
changed: onAlignmentChanged,
});
/**
* The change handler for the attached alignment properties.
*/
function onAlignmentChanged(child: Widget): void {
if (child.parent && child.parent.layout) {
child.parent.update();
}
}
}

View File

@@ -0,0 +1,112 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// Copyright (c) Jupyter Development Team.
// Distributed under the terms of the Modified BSD License.
/*-----------------------------------------------------------------------------
| Copyright (c) 2014-2017, PhosphorJS Contributors
|
| Distributed under the terms of the BSD 3-Clause License.
|
| The full license is in the file LICENSE, distributed with this software.
|----------------------------------------------------------------------------*/
import { Widget } from './widget';
import { PanelLayout } from './panellayout';
/**
* A simple and convenient panel widget class.
*
* #### Notes
* This class is suitable as a base class for implementing a variety of
* convenience panel widgets, but can also be used directly with CSS to
* arrange a collection of widgets.
*
* This class provides a convenience wrapper around a {@link PanelLayout}.
*/
export class Panel extends Widget {
/**
* Construct a new panel.
*
* @param options - The options for initializing the panel.
*/
constructor(options: Panel.IOptions = {}) {
super();
this.addClass('lm-Panel');
this.layout = Private.createLayout(options);
}
/**
* A read-only array of the widgets in the panel.
*/
get widgets(): ReadonlyArray<Widget> {
return (this.layout as PanelLayout).widgets;
}
/**
* Add a widget to the end of the panel.
*
* @param widget - The widget to add to the panel.
*
* #### Notes
* If the widget is already contained in the panel, it will be moved.
*/
addWidget(widget: Widget): void {
(this.layout as PanelLayout).addWidget(widget);
}
/**
* Insert a widget at the specified index.
*
* @param index - The index at which to insert the widget.
*
* @param widget - The widget to insert into to the panel.
*
* #### Notes
* If the widget is already contained in the panel, it will be moved.
*/
insertWidget(index: number, widget: Widget): void {
(this.layout as PanelLayout).insertWidget(index, widget);
}
}
/**
* The namespace for the `Panel` class statics.
*/
export namespace Panel {
/**
* An options object for creating a panel.
*/
export interface IOptions {
/**
* The panel layout to use for the panel.
*
* The default is a new `PanelLayout`.
*/
layout?: PanelLayout;
}
}
/**
* The namespace for the module implementation details.
*/
namespace Private {
/**
* Create a panel layout for the given panel options.
*/
export function createLayout(options: Panel.IOptions): PanelLayout {
return options.layout || new PanelLayout();
}
}

View File

@@ -0,0 +1,325 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// Copyright (c) Jupyter Development Team.
// Distributed under the terms of the Modified BSD License.
/*-----------------------------------------------------------------------------
| Copyright (c) 2014-2017, PhosphorJS Contributors
|
| Distributed under the terms of the BSD 3-Clause License.
|
| The full license is in the file LICENSE, distributed with this software.
|----------------------------------------------------------------------------*/
import { MessageLoop } from '../messaging';
import { ArrayExt } from '../algorithm';
import { Widget } from './widget';
import { Layout } from './layout';
/**
* A concrete layout implementation suitable for many use cases.
*
* #### Notes
* This class is suitable as a base class for implementing a variety of
* layouts, but can also be used directly with standard CSS to layout a
* collection of widgets.
*/
export class PanelLayout extends Layout {
/**
* Dispose of the resources held by the layout.
*
* #### Notes
* This will clear and dispose all widgets in the layout.
*
* All reimplementations should call the superclass method.
*
* This method is called automatically when the parent is disposed.
*/
dispose(): void {
while (this._widgets.length > 0) {
this._widgets.pop()!.dispose();
}
super.dispose();
}
/**
* A read-only array of the widgets in the layout.
*/
get widgets(): ReadonlyArray<Widget> {
return this._widgets;
}
/**
* Create an iterator over the widgets in the layout.
*
* @returns A new iterator over the widgets in the layout.
*/
*[Symbol.iterator](): IterableIterator<Widget> {
yield* this._widgets;
}
/**
* Add a widget to the end of the layout.
*
* @param widget - The widget to add to the layout.
*
* #### Notes
* If the widget is already contained in the layout, it will be moved.
*/
addWidget(widget: Widget): void {
this.insertWidget(this._widgets.length, widget);
}
/**
* Insert a widget into the layout at the specified index.
*
* @param index - The index at which to insert the widget.
*
* @param widget - The widget to insert into the layout.
*
* #### Notes
* The index will be clamped to the bounds of the widgets.
*
* If the widget is already added to the layout, it will be moved.
*
* #### Undefined Behavior
* An `index` which is non-integral.
*/
insertWidget(index: number, widget: Widget): void {
// Remove the widget from its current parent. This is a no-op
// if the widget's parent is already the layout parent widget.
widget.parent = this.parent;
// Look up the current index of the widget.
const i = this._widgets.indexOf(widget);
// Clamp the insert index to the array bounds.
let j = Math.max(0, Math.min(index, this._widgets.length));
// If the widget is not in the array, insert it.
if (i === -1) {
// Insert the widget into the array.
ArrayExt.insert(this._widgets, j, widget);
// If the layout is parented, attach the widget to the DOM.
if (this.parent) {
this.attachWidget(j, widget);
}
// There is nothing more to do.
return;
}
// Otherwise, the widget exists in the array and should be moved.
// Adjust the index if the location is at the end of the array.
if (j === this._widgets.length) {
j--;
}
// Bail if there is no effective move.
if (i === j) {
return;
}
// Move the widget to the new location.
ArrayExt.move(this._widgets, i, j);
// If the layout is parented, move the widget in the DOM.
if (this.parent) {
this.moveWidget(i, j, widget);
}
}
/**
* Remove a widget from the layout.
*
* @param widget - The widget to remove from the layout.
*
* #### Notes
* A widget is automatically removed from the layout when its `parent`
* is set to `null`. This method should only be invoked directly when
* removing a widget from a layout which has yet to be installed on a
* parent widget.
*
* This method does *not* modify the widget's `parent`.
*/
removeWidget(widget: Widget): void {
this.removeWidgetAt(this._widgets.indexOf(widget));
}
/**
* Remove the widget at a given index from the layout.
*
* @param index - The index of the widget to remove.
*
* #### Notes
* A widget is automatically removed from the layout when its `parent`
* is set to `null`. This method should only be invoked directly when
* removing a widget from a layout which has yet to be installed on a
* parent widget.
*
* This method does *not* modify the widget's `parent`.
*
* #### Undefined Behavior
* An `index` which is non-integral.
*/
removeWidgetAt(index: number): void {
// Remove the widget from the array.
const widget = ArrayExt.removeAt(this._widgets, index);
// If the layout is parented, detach the widget from the DOM.
if (widget && this.parent) {
this.detachWidget(index, widget);
}
}
/**
* Perform layout initialization which requires the parent widget.
*/
protected init(): void {
super.init();
let index = 0;
for (const widget of this) {
this.attachWidget(index++, widget);
}
}
/**
* Attach a widget to the parent's DOM node.
*
* @param index - The current index of the widget in the layout.
*
* @param widget - The widget to attach to the parent.
*
* #### Notes
* This method is called automatically by the panel layout at the
* appropriate time. It should not be called directly by user code.
*
* The default implementation adds the widgets's node to the parent's
* node at the proper location, and sends the appropriate attach
* messages to the widget if the parent is attached to the DOM.
*
* Subclasses may reimplement this method to control how the widget's
* node is added to the parent's node.
*/
protected attachWidget(index: number, widget: Widget): void {
// Look up the next sibling reference node.
const ref = this.parent!.node.children[index];
// Send a `'before-attach'` message if the parent is attached.
if (this.parent!.isAttached) {
MessageLoop.sendMessage(widget, Widget.Msg.BeforeAttach);
}
// Insert the widget's node before the sibling.
this.parent!.node.insertBefore(widget.node, ref);
// Send an `'after-attach'` message if the parent is attached.
if (this.parent!.isAttached) {
MessageLoop.sendMessage(widget, Widget.Msg.AfterAttach);
}
}
/**
* Move a widget in the parent's DOM node.
*
* @param fromIndex - The previous index of the widget in the layout.
*
* @param toIndex - The current index of the widget in the layout.
*
* @param widget - The widget to move in the parent.
*
* #### Notes
* This method is called automatically by the panel layout at the
* appropriate time. It should not be called directly by user code.
*
* The default implementation moves the widget's node to the proper
* location in the parent's node and sends the appropriate attach and
* detach messages to the widget if the parent is attached to the DOM.
*
* Subclasses may reimplement this method to control how the widget's
* node is moved in the parent's node.
*/
protected moveWidget(
fromIndex: number,
toIndex: number,
widget: Widget,
): void {
// Send a `'before-detach'` message if the parent is attached.
if (this.parent!.isAttached) {
MessageLoop.sendMessage(widget, Widget.Msg.BeforeDetach);
}
// Remove the widget's node from the parent.
this.parent!.node.removeChild(widget.node);
// Send an `'after-detach'` and message if the parent is attached.
if (this.parent!.isAttached) {
MessageLoop.sendMessage(widget, Widget.Msg.AfterDetach);
}
// Look up the next sibling reference node.
const ref = this.parent!.node.children[toIndex];
// Send a `'before-attach'` message if the parent is attached.
if (this.parent!.isAttached) {
MessageLoop.sendMessage(widget, Widget.Msg.BeforeAttach);
}
// Insert the widget's node before the sibling.
this.parent!.node.insertBefore(widget.node, ref);
// Send an `'after-attach'` message if the parent is attached.
if (this.parent!.isAttached) {
MessageLoop.sendMessage(widget, Widget.Msg.AfterAttach);
}
}
/**
* Detach a widget from the parent's DOM node.
*
* @param index - The previous index of the widget in the layout.
*
* @param widget - The widget to detach from the parent.
*
* #### Notes
* This method is called automatically by the panel layout at the
* appropriate time. It should not be called directly by user code.
*
* The default implementation removes the widget's node from the
* parent's node, and sends the appropriate detach messages to the
* widget if the parent is attached to the DOM.
*
* Subclasses may reimplement this method to control how the widget's
* node is removed from the parent's node.
*/
protected detachWidget(index: number, widget: Widget): void {
// Send a `'before-detach'` message if the parent is attached.
if (this.parent!.isAttached) {
MessageLoop.sendMessage(widget, Widget.Msg.BeforeDetach);
}
// Remove the widget's node from the parent.
this.parent!.node.removeChild(widget.node);
// Send an `'after-detach'` message if the parent is attached.
if (this.parent!.isAttached) {
MessageLoop.sendMessage(widget, Widget.Msg.AfterDetach);
}
}
private _widgets: Widget[] = [];
}

View File

@@ -0,0 +1,854 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// Copyright (c) Jupyter Development Team.
// Distributed under the terms of the Modified BSD License.
/*-----------------------------------------------------------------------------
| Copyright (c) 2014-2017, PhosphorJS Contributors
|
| Distributed under the terms of the BSD 3-Clause License.
|
| The full license is in the file LICENSE, distributed with this software.
|----------------------------------------------------------------------------*/
import { type ISignal, Signal } from '../signaling';
import { type Message } from '../messaging';
import { Drag } from '../dragdrop';
import { ElementExt } from '../domutils';
import { type IDisposable } from '../disposable';
import { Widget } from './widget';
/**
* A widget which implements a canonical scroll bar.
*/
export class ScrollBar extends Widget {
/**
* Construct a new scroll bar.
*
* @param options - The options for initializing the scroll bar.
*/
constructor(options: ScrollBar.IOptions = {}) {
super({ node: Private.createNode() });
this.addClass('lm-ScrollBar');
this.setFlag(Widget.Flag.DisallowLayout);
// Set the orientation.
this._orientation = options.orientation || 'vertical';
this.dataset.orientation = this._orientation;
// Parse the rest of the options.
if (options.maximum !== undefined) {
this._maximum = Math.max(0, options.maximum);
}
if (options.page !== undefined) {
this._page = Math.max(0, options.page);
}
if (options.value !== undefined) {
this._value = Math.max(0, Math.min(options.value, this._maximum));
}
}
/**
* A signal emitted when the user moves the scroll thumb.
*
* #### Notes
* The payload is the current value of the scroll bar.
*/
get thumbMoved(): ISignal<this, number> {
return this._thumbMoved;
}
/**
* A signal emitted when the user clicks a step button.
*
* #### Notes
* The payload is whether a decrease or increase is requested.
*/
get stepRequested(): ISignal<this, 'decrement' | 'increment'> {
return this._stepRequested;
}
/**
* A signal emitted when the user clicks the scroll track.
*
* #### Notes
* The payload is whether a decrease or increase is requested.
*/
get pageRequested(): ISignal<this, 'decrement' | 'increment'> {
return this._pageRequested;
}
/**
* Get the orientation of the scroll bar.
*/
get orientation(): ScrollBar.Orientation {
return this._orientation;
}
/**
* Set the orientation of the scroll bar.
*/
set orientation(value: ScrollBar.Orientation) {
// Do nothing if the orientation does not change.
if (this._orientation === value) {
return;
}
// Release the mouse before making changes.
this._releaseMouse();
// Update the internal orientation.
this._orientation = value;
this.dataset.orientation = value;
// Schedule an update the scroll bar.
this.update();
}
/**
* Get the current value of the scroll bar.
*/
get value(): number {
return this._value;
}
/**
* Set the current value of the scroll bar.
*
* #### Notes
* The value will be clamped to the range `[0, maximum]`.
*/
set value(value: number) {
// Clamp the value to the allowable range.
value = Math.max(0, Math.min(value, this._maximum));
// Do nothing if the value does not change.
if (this._value === value) {
return;
}
// Update the internal value.
this._value = value;
// Schedule an update the scroll bar.
this.update();
}
/**
* Get the page size of the scroll bar.
*
* #### Notes
* The page size is the amount of visible content in the scrolled
* region, expressed in data units. It determines the size of the
* scroll bar thumb.
*/
get page(): number {
return this._page;
}
/**
* Set the page size of the scroll bar.
*
* #### Notes
* The page size will be clamped to the range `[0, Infinity]`.
*/
set page(value: number) {
// Clamp the page size to the allowable range.
value = Math.max(0, value);
// Do nothing if the value does not change.
if (this._page === value) {
return;
}
// Update the internal page size.
this._page = value;
// Schedule an update the scroll bar.
this.update();
}
/**
* Get the maximum value of the scroll bar.
*/
get maximum(): number {
return this._maximum;
}
/**
* Set the maximum value of the scroll bar.
*
* #### Notes
* The max size will be clamped to the range `[0, Infinity]`.
*/
set maximum(value: number) {
// Clamp the value to the allowable range.
value = Math.max(0, value);
// Do nothing if the value does not change.
if (this._maximum === value) {
return;
}
// Update the internal values.
this._maximum = value;
// Clamp the current value to the new range.
this._value = Math.min(this._value, value);
// Schedule an update the scroll bar.
this.update();
}
/**
* The scroll bar decrement button node.
*
* #### Notes
* Modifying this node directly can lead to undefined behavior.
*/
get decrementNode(): HTMLDivElement {
return this.node.getElementsByClassName(
'lm-ScrollBar-button',
)[0] as HTMLDivElement;
}
/**
* The scroll bar increment button node.
*
* #### Notes
* Modifying this node directly can lead to undefined behavior.
*/
get incrementNode(): HTMLDivElement {
return this.node.getElementsByClassName(
'lm-ScrollBar-button',
)[1] as HTMLDivElement;
}
/**
* The scroll bar track node.
*
* #### Notes
* Modifying this node directly can lead to undefined behavior.
*/
get trackNode(): HTMLDivElement {
return this.node.getElementsByClassName(
'lm-ScrollBar-track',
)[0] as HTMLDivElement;
}
/**
* The scroll bar thumb node.
*
* #### Notes
* Modifying this node directly can lead to undefined behavior.
*/
get thumbNode(): HTMLDivElement {
return this.node.getElementsByClassName(
'lm-ScrollBar-thumb',
)[0] as HTMLDivElement;
}
/**
* Handle the DOM events for the scroll bar.
*
* @param event - The DOM event sent to the scroll bar.
*
* #### Notes
* This method implements the DOM `EventListener` interface and is
* called in response to events on the scroll bar's DOM node.
*
* This should not be called directly by user code.
*/
handleEvent(event: Event): void {
switch (event.type) {
case 'mousedown':
this._evtMouseDown(event as MouseEvent);
break;
case 'mousemove':
this._evtMouseMove(event as MouseEvent);
break;
case 'mouseup':
this._evtMouseUp(event as MouseEvent);
break;
case 'keydown':
this._evtKeyDown(event as KeyboardEvent);
break;
case 'contextmenu':
event.preventDefault();
event.stopPropagation();
break;
}
}
/**
* A method invoked on a 'before-attach' message.
*/
protected onBeforeAttach(msg: Message): void {
this.node.addEventListener('mousedown', this);
this.update();
}
/**
* A method invoked on an 'after-detach' message.
*/
protected onAfterDetach(msg: Message): void {
this.node.removeEventListener('mousedown', this);
this._releaseMouse();
}
/**
* A method invoked on an 'update-request' message.
*/
protected onUpdateRequest(msg: Message): void {
// Convert the value and page into percentages.
let value = (this._value * 100) / this._maximum;
let page = (this._page * 100) / (this._page + this._maximum);
// Clamp the value and page to the relevant range.
value = Math.max(0, Math.min(value, 100));
page = Math.max(0, Math.min(page, 100));
// Fetch the thumb style.
const thumbStyle = this.thumbNode.style;
// Update the thumb style for the current orientation.
if (this._orientation === 'horizontal') {
thumbStyle.top = '';
thumbStyle.height = '';
thumbStyle.left = `${value}%`;
thumbStyle.width = `${page}%`;
thumbStyle.transform = `translate(${-value}%, 0%)`;
} else {
thumbStyle.left = '';
thumbStyle.width = '';
thumbStyle.top = `${value}%`;
thumbStyle.height = `${page}%`;
thumbStyle.transform = `translate(0%, ${-value}%)`;
}
}
/**
* Handle the `'keydown'` event for the scroll bar.
*/
private _evtKeyDown(event: KeyboardEvent): void {
// Stop all input events during drag.
event.preventDefault();
event.stopPropagation();
// Ignore anything except the `Escape` key.
if (event.keyCode !== 27) {
return;
}
// Fetch the previous scroll value.
const value = this._pressData ? this._pressData.value : -1;
// Release the mouse.
this._releaseMouse();
// Restore the old scroll value if possible.
if (value !== -1) {
this._moveThumb(value);
}
}
/**
* Handle the `'mousedown'` event for the scroll bar.
*/
private _evtMouseDown(event: MouseEvent): void {
// Do nothing if it's not a left mouse press.
if (event.button !== 0) {
return;
}
// Send an activate request to the scroll bar. This can be
// used by message hooks to activate something relevant.
this.activate();
// Do nothing if the mouse is already captured.
if (this._pressData) {
return;
}
// Find the pressed scroll bar part.
const part = Private.findPart(this, event.target as HTMLElement);
// Do nothing if the part is not of interest.
if (!part) {
return;
}
// Stop the event propagation.
event.preventDefault();
event.stopPropagation();
// Override the mouse cursor.
const override = Drag.overrideCursor('default');
// Set up the press data.
this._pressData = {
part,
override,
delta: -1,
value: -1,
mouseX: event.clientX,
mouseY: event.clientY,
};
// Add the extra event listeners.
document.addEventListener('mousemove', this, true);
document.addEventListener('mouseup', this, true);
document.addEventListener('keydown', this, true);
document.addEventListener('contextmenu', this, true);
// Handle a thumb press.
if (part === 'thumb') {
// Fetch the thumb node.
const { thumbNode } = this;
// Fetch the client rect for the thumb.
const thumbRect = thumbNode.getBoundingClientRect();
// Update the press data delta for the current orientation.
if (this._orientation === 'horizontal') {
this._pressData.delta = event.clientX - thumbRect.left;
} else {
this._pressData.delta = event.clientY - thumbRect.top;
}
// Add the active class to the thumb node.
thumbNode.classList.add('lm-mod-active');
// Store the current value in the press data.
this._pressData.value = this._value;
// Finished.
return;
}
// Handle a track press.
if (part === 'track') {
// Fetch the client rect for the thumb.
const thumbRect = this.thumbNode.getBoundingClientRect();
// Determine the direction for the page request.
let dir: 'decrement' | 'increment';
if (this._orientation === 'horizontal') {
dir = event.clientX < thumbRect.left ? 'decrement' : 'increment';
} else {
dir = event.clientY < thumbRect.top ? 'decrement' : 'increment';
}
// Start the repeat timer.
this._repeatTimer = window.setTimeout(this._onRepeat, 350);
// Emit the page requested signal.
this._pageRequested.emit(dir);
// Finished.
return;
}
// Handle a decrement button press.
if (part === 'decrement') {
// Add the active class to the decrement node.
this.decrementNode.classList.add('lm-mod-active');
// Start the repeat timer.
this._repeatTimer = window.setTimeout(this._onRepeat, 350);
// Emit the step requested signal.
this._stepRequested.emit('decrement');
// Finished.
return;
}
// Handle an increment button press.
if (part === 'increment') {
// Add the active class to the increment node.
this.incrementNode.classList.add('lm-mod-active');
// Start the repeat timer.
this._repeatTimer = window.setTimeout(this._onRepeat, 350);
// Emit the step requested signal.
this._stepRequested.emit('increment');
// Finished.
return;
}
}
/**
* Handle the `'mousemove'` event for the scroll bar.
*/
private _evtMouseMove(event: MouseEvent): void {
// Do nothing if no drag is in progress.
if (!this._pressData) {
return;
}
// Stop the event propagation.
event.preventDefault();
event.stopPropagation();
// Update the mouse position.
this._pressData.mouseX = event.clientX;
this._pressData.mouseY = event.clientY;
// Bail if the thumb is not being dragged.
if (this._pressData.part !== 'thumb') {
return;
}
// Get the client rect for the thumb and track.
const thumbRect = this.thumbNode.getBoundingClientRect();
const trackRect = this.trackNode.getBoundingClientRect();
// Fetch the scroll geometry based on the orientation.
let trackPos: number;
let trackSpan: number;
if (this._orientation === 'horizontal') {
trackPos = event.clientX - trackRect.left - this._pressData.delta;
trackSpan = trackRect.width - thumbRect.width;
} else {
trackPos = event.clientY - trackRect.top - this._pressData.delta;
trackSpan = trackRect.height - thumbRect.height;
}
// Compute the desired value from the scroll geometry.
const value = trackSpan === 0 ? 0 : (trackPos * this._maximum) / trackSpan;
// Move the thumb to the computed value.
this._moveThumb(value);
}
/**
* Handle the `'mouseup'` event for the scroll bar.
*/
private _evtMouseUp(event: MouseEvent): void {
// Do nothing if it's not a left mouse release.
if (event.button !== 0) {
return;
}
// Stop the event propagation.
event.preventDefault();
event.stopPropagation();
// Release the mouse.
this._releaseMouse();
}
/**
* Release the mouse and restore the node states.
*/
private _releaseMouse(): void {
// Bail if there is no press data.
if (!this._pressData) {
return;
}
// Clear the repeat timer.
clearTimeout(this._repeatTimer);
this._repeatTimer = -1;
// Clear the press data.
this._pressData.override.dispose();
this._pressData = null;
// Remove the extra event listeners.
document.removeEventListener('mousemove', this, true);
document.removeEventListener('mouseup', this, true);
document.removeEventListener('keydown', this, true);
document.removeEventListener('contextmenu', this, true);
// Remove the active classes from the nodes.
this.thumbNode.classList.remove('lm-mod-active');
this.decrementNode.classList.remove('lm-mod-active');
this.incrementNode.classList.remove('lm-mod-active');
}
/**
* Move the thumb to the specified position.
*/
private _moveThumb(value: number): void {
// Clamp the value to the allowed range.
value = Math.max(0, Math.min(value, this._maximum));
// Bail if the value does not change.
if (this._value === value) {
return;
}
// Update the internal value.
this._value = value;
// Schedule an update of the scroll bar.
this.update();
// Emit the thumb moved signal.
this._thumbMoved.emit(value);
}
/**
* A timeout callback for repeating the mouse press.
*/
private _onRepeat = () => {
// Clear the repeat timer id.
this._repeatTimer = -1;
// Bail if the mouse has been released.
if (!this._pressData) {
return;
}
// Look up the part that was pressed.
const { part } = this._pressData;
// Bail if the thumb was pressed.
if (part === 'thumb') {
return;
}
// Schedule the timer for another repeat.
this._repeatTimer = window.setTimeout(this._onRepeat, 20);
// Get the current mouse position.
const { mouseX } = this._pressData;
const { mouseY } = this._pressData;
// Handle a decrement button repeat.
if (part === 'decrement') {
// Bail if the mouse is not over the button.
if (!ElementExt.hitTest(this.decrementNode, mouseX, mouseY)) {
return;
}
// Emit the step requested signal.
this._stepRequested.emit('decrement');
// Finished.
return;
}
// Handle an increment button repeat.
if (part === 'increment') {
// Bail if the mouse is not over the button.
if (!ElementExt.hitTest(this.incrementNode, mouseX, mouseY)) {
return;
}
// Emit the step requested signal.
this._stepRequested.emit('increment');
// Finished.
return;
}
// Handle a track repeat.
if (part === 'track') {
// Bail if the mouse is not over the track.
if (!ElementExt.hitTest(this.trackNode, mouseX, mouseY)) {
return;
}
// Fetch the thumb node.
const { thumbNode } = this;
// Bail if the mouse is over the thumb.
if (ElementExt.hitTest(thumbNode, mouseX, mouseY)) {
return;
}
// Fetch the client rect for the thumb.
const thumbRect = thumbNode.getBoundingClientRect();
// Determine the direction for the page request.
let dir: 'decrement' | 'increment';
if (this._orientation === 'horizontal') {
dir = mouseX < thumbRect.left ? 'decrement' : 'increment';
} else {
dir = mouseY < thumbRect.top ? 'decrement' : 'increment';
}
// Emit the page requested signal.
this._pageRequested.emit(dir);
// Finished.
return;
}
};
private _value = 0;
private _page = 10;
private _maximum = 100;
private _repeatTimer = -1;
private _orientation: ScrollBar.Orientation;
private _pressData: Private.IPressData | null = null;
private _thumbMoved = new Signal<this, number>(this);
private _stepRequested = new Signal<this, 'decrement' | 'increment'>(this);
private _pageRequested = new Signal<this, 'decrement' | 'increment'>(this);
}
/**
* The namespace for the `ScrollBar` class statics.
*/
export namespace ScrollBar {
/**
* A type alias for a scroll bar orientation.
*/
export type Orientation = 'horizontal' | 'vertical';
/**
* An options object for creating a scroll bar.
*/
export interface IOptions {
/**
* The orientation of the scroll bar.
*
* The default is `'vertical'`.
*/
orientation?: Orientation;
/**
* The value for the scroll bar.
*
* The default is `0`.
*/
value?: number;
/**
* The page size for the scroll bar.
*
* The default is `10`.
*/
page?: number;
/**
* The maximum value for the scroll bar.
*
* The default is `100`.
*/
maximum?: number;
}
}
/**
* The namespace for the module implementation details.
*/
namespace Private {
/**
* A type alias for the parts of a scroll bar.
*/
export type ScrollBarPart = 'thumb' | 'track' | 'decrement' | 'increment';
/**
* An object which holds mouse press data.
*/
export interface IPressData {
/**
* The scroll bar part which was pressed.
*/
part: ScrollBarPart;
/**
* The offset of the press in thumb coordinates, or -1.
*/
delta: number;
/**
* The scroll value at the time the thumb was pressed, or -1.
*/
value: number;
/**
* The disposable which will clear the override cursor.
*/
override: IDisposable;
/**
* The current X position of the mouse.
*/
mouseX: number;
/**
* The current Y position of the mouse.
*/
mouseY: number;
}
/**
* Create the DOM node for a scroll bar.
*/
export function createNode(): HTMLElement {
const node = document.createElement('div');
const decrement = document.createElement('div');
const increment = document.createElement('div');
const track = document.createElement('div');
const thumb = document.createElement('div');
decrement.className = 'lm-ScrollBar-button';
increment.className = 'lm-ScrollBar-button';
decrement.dataset.action = 'decrement';
increment.dataset.action = 'increment';
track.className = 'lm-ScrollBar-track';
thumb.className = 'lm-ScrollBar-thumb';
track.appendChild(thumb);
node.appendChild(decrement);
node.appendChild(track);
node.appendChild(increment);
return node;
}
/**
* Find the scroll bar part which contains the given target.
*/
export function findPart(
scrollBar: ScrollBar,
target: HTMLElement,
): ScrollBarPart | null {
// Test the thumb.
if (scrollBar.thumbNode.contains(target)) {
return 'thumb';
}
// Test the track.
if (scrollBar.trackNode.contains(target)) {
return 'track';
}
// Test the decrement button.
if (scrollBar.decrementNode.contains(target)) {
return 'decrement';
}
// Test the increment button.
if (scrollBar.incrementNode.contains(target)) {
return 'increment';
}
// Indicate no match.
return null;
}
}

View File

@@ -0,0 +1,205 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// Copyright (c) Jupyter Development Team.
// Distributed under the terms of the Modified BSD License.
/*-----------------------------------------------------------------------------
| Copyright (c) 2014-2017, PhosphorJS Contributors
|
| Distributed under the terms of the BSD 3-Clause License.
|
| The full license is in the file LICENSE, distributed with this software.
|----------------------------------------------------------------------------*/
import { MessageLoop } from '../messaging';
import { Widget } from './widget';
import { Layout } from './layout';
/**
* A concrete layout implementation which holds a single widget.
*
* #### Notes
* This class is useful for creating simple container widgets which
* hold a single child. The child should be positioned with CSS.
*/
export class SingletonLayout extends Layout {
/**
* Dispose of the resources held by the layout.
*/
dispose(): void {
if (this._widget) {
const widget = this._widget;
this._widget = null;
widget.dispose();
}
super.dispose();
}
/**
* Get the child widget for the layout.
*/
get widget(): Widget | null {
return this._widget;
}
/**
* Set the child widget for the layout.
*
* #### Notes
* Setting the child widget will cause the old child widget to be
* automatically disposed. If that is not desired, set the parent
* of the old child to `null` before assigning a new child.
*/
set widget(widget: Widget | null) {
// Remove the widget from its current parent. This is a no-op
// if the widget's parent is already the layout parent widget.
if (widget) {
widget.parent = this.parent;
}
// Bail early if the widget does not change.
if (this._widget === widget) {
return;
}
// Dispose of the old child widget.
if (this._widget) {
this._widget.dispose();
}
// Update the internal widget.
this._widget = widget;
// Attach the new child widget if needed.
if (this.parent && widget) {
this.attachWidget(widget);
}
}
/**
* Create an iterator over the widgets in the layout.
*
* @returns A new iterator over the widgets in the layout.
*/
*[Symbol.iterator](): IterableIterator<Widget> {
if (this._widget) {
yield this._widget;
}
}
/**
* Remove a widget from the layout.
*
* @param widget - The widget to remove from the layout.
*
* #### Notes
* A widget is automatically removed from the layout when its `parent`
* is set to `null`. This method should only be invoked directly when
* removing a widget from a layout which has yet to be installed on a
* parent widget.
*
* This method does *not* modify the widget's `parent`.
*/
removeWidget(widget: Widget): void {
// Bail early if the widget does not exist in the layout.
if (this._widget !== widget) {
return;
}
// Clear the internal widget.
this._widget = null;
// If the layout is parented, detach the widget from the DOM.
if (this.parent) {
this.detachWidget(widget);
}
}
/**
* Perform layout initialization which requires the parent widget.
*/
protected init(): void {
super.init();
for (const widget of this) {
this.attachWidget(widget);
}
}
/**
* Attach a widget to the parent's DOM node.
*
* @param index - The current index of the widget in the layout.
*
* @param widget - The widget to attach to the parent.
*
* #### Notes
* This method is called automatically by the single layout at the
* appropriate time. It should not be called directly by user code.
*
* The default implementation adds the widgets's node to the parent's
* node at the proper location, and sends the appropriate attach
* messages to the widget if the parent is attached to the DOM.
*
* Subclasses may reimplement this method to control how the widget's
* node is added to the parent's node.
*/
protected attachWidget(widget: Widget): void {
// Send a `'before-attach'` message if the parent is attached.
if (this.parent!.isAttached) {
MessageLoop.sendMessage(widget, Widget.Msg.BeforeAttach);
}
// Add the widget's node to the parent.
this.parent!.node.appendChild(widget.node);
// Send an `'after-attach'` message if the parent is attached.
if (this.parent!.isAttached) {
MessageLoop.sendMessage(widget, Widget.Msg.AfterAttach);
}
}
/**
* Detach a widget from the parent's DOM node.
*
* @param widget - The widget to detach from the parent.
*
* #### Notes
* This method is called automatically by the single layout at the
* appropriate time. It should not be called directly by user code.
*
* The default implementation removes the widget's node from the
* parent's node, and sends the appropriate detach messages to the
* widget if the parent is attached to the DOM.
*
* Subclasses may reimplement this method to control how the widget's
* node is removed from the parent's node.
*/
protected detachWidget(widget: Widget): void {
// Send a `'before-detach'` message if the parent is attached.
if (this.parent!.isAttached) {
MessageLoop.sendMessage(widget, Widget.Msg.BeforeDetach);
}
// Remove the widget's node from the parent.
this.parent!.node.removeChild(widget.node);
// Send an `'after-detach'` message if the parent is attached.
if (this.parent!.isAttached) {
MessageLoop.sendMessage(widget, Widget.Msg.AfterDetach);
}
}
private _widget: Widget | null = null;
}

Some files were not shown because too many files have changed in this diff Show More