diff --git a/.github/workflows/manual-vscode-boxel-tools.yml b/.github/workflows/manual-vscode-boxel-tools.yml index 61df8cb269..cdfb00fb54 100644 --- a/.github/workflows/manual-vscode-boxel-tools.yml +++ b/.github/workflows/manual-vscode-boxel-tools.yml @@ -94,7 +94,7 @@ jobs: - name: Package run: pnpm vscode:package working-directory: packages/vscode-boxel-tools - - name: Publish + - name: Publish to Visual Studio Marketplace run: | if [ "${{ inputs.environment }}" = "production" ]; then pnpm vscode:publish @@ -104,3 +104,13 @@ jobs: working-directory: packages/vscode-boxel-tools env: VSCE_PAT: ${{ secrets.VSCE_PAT }} + - name: Publish to Open VSX + run: | + if [ "${{ inputs.environment }}" = "production" ]; then + npx ovsx publish --no-dependencies --pat $OVSX_TOKEN + else + npx ovsx publish --no-dependencies --pre-release --pat $OVSX_TOKEN + fi + working-directory: packages/vscode-boxel-tools + env: + OVSX_TOKEN: ${{ secrets.OVSX_TOKEN }} diff --git a/packages/ai-bot/helpers.ts b/packages/ai-bot/helpers.ts index 6598964489..1eaef7a605 100644 --- a/packages/ai-bot/helpers.ts +++ b/packages/ai-bot/helpers.ts @@ -119,7 +119,14 @@ export function constructHistory( try { rawEvent.content.data = JSON.parse(rawEvent.content.data); } catch (e) { - Sentry.captureException(e); + Sentry.captureException(e, { + attachments: [ + { + data: rawEvent.content.data, + filename: 'rawEventContentData.txt', + }, + ], + }); log.error('Error parsing JSON', e); throw new HistoryConstructionError((e as Error).message); } diff --git a/packages/base/base64-image.gts b/packages/base/base64-image.gts index c983bc188b..8f9df3dff7 100644 --- a/packages/base/base64-image.gts +++ b/packages/base/base64-image.gts @@ -197,7 +197,7 @@ class Edit extends Component { // this allows multiple radio groups rendered on the page // to stay independent of one another. let groupNumber = 0; -class ImageSizeField extends FieldDef { +export class ImageSizeField extends FieldDef { static displayName = 'Image Size'; static [primitive]: 'actual' | 'contain' | 'cover'; static [useIndexBasedKey]: never; diff --git a/packages/base/cards-grid.gts b/packages/base/cards-grid.gts index a606a1858e..10038734b0 100644 --- a/packages/base/cards-grid.gts +++ b/packages/base/cards-grid.gts @@ -286,8 +286,22 @@ class Isolated extends Component { display: none; } .instance-error { - opacity: 0.33; - color: var(--boxel-error-100); + position: relative; + } + .instance-error::after { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(255, 0, 0, 0.1); + } + .instance-error .boundaries { + box-shadow: 0 0 0 1px var(--boxel-error-300); + } + .instance-error:hover .boundaries { + box-shadow: 0 0 0 1px var(--boxel-dark); } diff --git a/packages/base/command.gts b/packages/base/command.gts index 38cbcf1bde..ca578415d2 100644 --- a/packages/base/command.gts +++ b/packages/base/command.gts @@ -6,11 +6,13 @@ import { contains, field, linksTo, + linksToMany, primitive, queryableValue, } from './card-api'; import CodeRefField from './code-ref'; import BooleanField from './boolean'; +import { SkillCard } from './skill-card'; export type CommandStatus = 'applied' | 'ready' | 'applying'; @@ -75,3 +77,16 @@ export class CreateInstanceInput extends CardDef { @field module = contains(CodeRefField); @field realm = contains(StringField); } + +export class CreateAIAssistantRoomInput extends CardDef { + @field name = contains(StringField); +} + +export class CreateAIAssistantRoomResult extends CardDef { + @field roomId = contains(StringField); +} + +export class AddSkillsToRoomInput extends CardDef { + @field roomId = contains(StringField); + @field skills = linksToMany(SkillCard); +} diff --git a/packages/base/markdown.gts b/packages/base/markdown.gts index 432e21ae41..7ca3d329c6 100644 --- a/packages/base/markdown.gts +++ b/packages/base/markdown.gts @@ -14,7 +14,7 @@ class View extends Component { - @tracked private panelGroupElement: HTMLDivElement | undefined; + private id = guidFor(this); + element!: HTMLDivElement; + private layout: TrackedArray = new TrackedArray(); + private panels: ResizablePanel[] = []; + private panelsChanged = false; + private panelSizeBeforeCollapse: Map = new Map(); + + @tracked private dragState: DragState | null = null; @tracked hideHandles = false; minimumLengthToShowHandles = 30; - resizablePanelIdCache = new WeakMap(); - panels = new TrackedArray(); - resizeHandles = new TrackedArray(); - - private initializationWaiter = waiter.beginAsync(); - - currentResizeHandle: { - handle: ResizeHandle; - initialPosition: number; - nextPanel?: ResizablePanel | null; - prevPanel?: ResizablePanel | null; - } | null = null; - - constructor(args: any, owner: any) { - super(args, owner); - - document.addEventListener('mouseup', this.onResizeHandleMouseUp); - document.addEventListener('mousemove', this.onResizeHandleMouseMove); - - registerDestructor(this, () => { - document.removeEventListener('mouseup', this.onResizeHandleMouseUp); - document.removeEventListener('mousedown', this.onResizeHandleMouseMove); - }); - } - - private get reverseCollapse() { - return this.args.reverseCollapse ?? false; - } - - private get isHorizontal() { - return this.args.orientation === 'horizontal'; - } - - private get clientPositionProperty() { - return this.isHorizontal ? 'clientX' : 'clientY'; - } - - private get clientLengthProperty() { - return this.isHorizontal ? 'clientWidth' : 'clientHeight'; - } - - private get offsetLengthProperty() { - return this.isHorizontal ? 'offsetWidth' : 'offsetHeight'; - } - - private get perpendicularLengthProperty() { - return this.isHorizontal ? 'clientHeight' : 'clientWidth'; - } - - private get panelGroupLengthPx() { - return this.panelGroupElement?.[this.offsetLengthProperty]; - } - - private get panelGroupLengthWithoutResizeHandlePx() { - let totalResizeHandleLength = this.resizeHandles.reduce( - (prevValue, handle) => { - return prevValue + handle.element[this.offsetLengthProperty]; - }, - 0, - ); - let panelGroupElement = this.panelGroupElement; - if (panelGroupElement === undefined) { - console.warn('Expected panelGroupElement to be defined'); - return undefined; - } - let panelGroupLengthPx = this.panelGroupLengthPx; - if (panelGroupLengthPx === undefined) { - console.warn('Expected panelGroupLengthPx to be defined'); - return undefined; - } - - return panelGroupLengthPx - totalResizeHandleLength; - } + initializationWaiter = waiter.beginAsync(); @action registerPanel(panel: ResizablePanel) { - if (panel.lengthPx === undefined) { - if ( - this.panelGroupLengthPx === undefined || - panel.defaultLengthFraction === undefined - ) { - panel.lengthPx = -1; - } else if (panel.isHidden) { - panel.lengthPx = 0; + this.panels.push(panel); + this.panelsChanged = true; + + return () => { + let flexGrow; + let size = this.layout[this.panels.indexOf(panel)]; + if (size == null) { + // Initial render (before panels have registered themselves) + // In order to support server rendering, fall back to default size if provided + flexGrow = + panel.constraints.defaultSize != undefined + ? panel.constraints.defaultSize.toPrecision(PRECISION) + : '1'; + } else if (this.panels.length === 1) { + // Special case: Single panel group should always fill full width/height + flexGrow = '1'; } else { - panel.lengthPx = panel.defaultLengthFraction * this.panelGroupLengthPx; + flexGrow = size.toPrecision(PRECISION); } - } - this.panels.push(panel); - this.calculatePanelRatio(); + return htmlSafe( + `flex: 0; flex-grow: ${flexGrow}; flex-shrink: 1; overflow: hidden; pointer-events: ${ + this.dragState !== null ? 'none' : undefined + };`, + ); + }; } @action @@ -199,422 +174,168 @@ export default class ResizablePanelGroup extends Component { if (panelIndex > -1) { this.panels.splice(panelIndex, 1); - this.calculatePanelRatio(); - } - } - - calculatePanelRatio() { - let panelLengths = this.panels.map((panel) => panel.lengthPx ?? 0); - let totalPanelLength = sumArray(panelLengths); - - for (let index = 0; index < panelLengths.length; index++) { - let panelLength = panelLengths[index]; - let panel = this.panels[index]; - if (panelLength == undefined || !panel) { - break; - } - panel.ratio = panelLength / totalPanelLength; - } - } - - @action - registerHandle(handle: ResizeHandle) { - this.resizeHandles.push(handle); - } - - @action - unregisterHandle(handle: ResizeHandle) { - let handleIndex = this.resizeHandles.indexOf(handle); - if (handleIndex > -1) { - this.resizeHandles.splice(handleIndex, 1); + this.panelsChanged = true; } } @action - onHandleMouseDown(event: MouseEvent) { - let button = event.target as HTMLElement; - - let handle = this.resizeHandles.find( - (handle) => handle.element === button.parentNode, - ); - - if (this.currentResizeHandle || !handle) { - return; - } - - let { prevPanel, nextPanel } = this.findPanelsByResizeHandle(handle); - if (!prevPanel || !nextPanel) { - console.warn('prevPanelEl and nextPanelEl are required on drag'); - return undefined; - } - this.currentResizeHandle = { - handle, - initialPosition: event[this.clientPositionProperty], - prevPanel: prevPanel, - nextPanel: nextPanel, - }; - } - - @action - onResizeHandleMouseUp(_event: MouseEvent) { - this.currentResizeHandle = null; - } - - @action - onResizeHandleMouseMove(event: MouseEvent) { - if ( - !this.currentResizeHandle || - !this.currentResizeHandle.prevPanel || - !this.currentResizeHandle.nextPanel - ) { - return; - } - - if (!this.isCursorInTheRightPlace(event)) { - let resizeHandleRect = - this.currentResizeHandle.handle.element.getBoundingClientRect(); - this.currentResizeHandle.initialPosition = Math.round( - (resizeHandleRect.left + resizeHandleRect.right) / 2, - ); - return; - } - - let delta = - event[this.clientPositionProperty] - - this.currentResizeHandle.initialPosition; - if (delta === 0) { - return; - } - - let newPrevPanelElLength = - this.currentResizeHandle.prevPanel.element[this.clientLengthProperty] + - delta; - let newNextPanelElLength = - this.currentResizeHandle.nextPanel.element[this.clientLengthProperty] - - delta; - let prevPanel = this.currentResizeHandle.prevPanel; - let nextPanel = this.currentResizeHandle.nextPanel; - - if (!prevPanel || !nextPanel) { - console.warn( - 'Expected prevPanel && nextPanel to be defined when dragging handle', - ); - return; - } - - if (newPrevPanelElLength < 0 && newNextPanelElLength > 0) { - newNextPanelElLength = newNextPanelElLength + newPrevPanelElLength; - newPrevPanelElLength = 0; - } else if (newPrevPanelElLength > 0 && newNextPanelElLength < 0) { - newPrevPanelElLength = newPrevPanelElLength + newNextPanelElLength; - newNextPanelElLength = 0; - } else if ( - prevPanel.initialMinLengthPx && - newPrevPanelElLength < prevPanel.initialMinLengthPx && - newPrevPanelElLength > (prevPanel.lengthPx || 0) - ) { - newNextPanelElLength = - newNextPanelElLength - - (prevPanel.initialMinLengthPx - newPrevPanelElLength); - newPrevPanelElLength = prevPanel.initialMinLengthPx; - } else if ( - nextPanel.initialMinLengthPx && - newNextPanelElLength < nextPanel.initialMinLengthPx && - newNextPanelElLength > (nextPanel.lengthPx || 0) - ) { - newPrevPanelElLength = - newPrevPanelElLength + - (nextPanel.initialMinLengthPx - newNextPanelElLength); - newNextPanelElLength = nextPanel.initialMinLengthPx; - } else if ( - prevPanel.initialMinLengthPx && - newPrevPanelElLength < prevPanel.initialMinLengthPx && - newPrevPanelElLength < (prevPanel.lengthPx || 0) - ) { - newNextPanelElLength = newNextPanelElLength + newPrevPanelElLength; - newPrevPanelElLength = 0; - } else if ( - nextPanel.initialMinLengthPx && - newNextPanelElLength < nextPanel.initialMinLengthPx && - newNextPanelElLength < (nextPanel.lengthPx || 0) - ) { - newPrevPanelElLength = newPrevPanelElLength + newNextPanelElLength; - newNextPanelElLength = 0; - } - - this.setSiblingPanelLengths( - prevPanel, - nextPanel, - newPrevPanelElLength, - newNextPanelElLength, - (prevPanel.initialMinLengthPx && - newPrevPanelElLength >= prevPanel.initialMinLengthPx) || - !prevPanel.collapsible - ? prevPanel.initialMinLengthPx - : 0, - (nextPanel.initialMinLengthPx && - newNextPanelElLength >= nextPanel.initialMinLengthPx) || - !nextPanel.collapsible - ? nextPanel.initialMinLengthPx - : 0, - ); - - this.currentResizeHandle.initialPosition = - event[this.clientPositionProperty]; - - this.calculatePanelRatio(); - } - - // This event only applies to the first and last resize handler. - // When triggered, it will close either the first or last panel. - // In this scenario, the minimum length of the panel will be disregarded. - @action - onHandleDoubleClick(event: MouseEvent) { - let handleElement = event.target as HTMLElement; - let handle = this.resizeHandles.find( - (handle) => handle.element === handleElement.parentNode, - ); - - if (!handle) { - console.warn('Could not find handle'); - return; - } - - let isFirstButton = this.resizeHandles.indexOf(handle) === 0; - let isLastButton = - this.resizeHandles.indexOf(handle) === this.resizeHandles.length - 1; - - let panelGroupLengthPx = this.panelGroupLengthWithoutResizeHandlePx; - if (panelGroupLengthPx === undefined) { - console.warn('Expected panelGroupLengthPx to be defined'); - return undefined; - } - - let { prevPanel, nextPanel } = this.findPanelsByResizeHandle(handle); - if (!prevPanel || !nextPanel) { - console.warn('prevPanel and nextPanel are required on double-click'); - return undefined; - } - - let prevPanelElLength = prevPanel.lengthPx || 0; - let nextPanelElLength = nextPanel.lengthPx || 0; - - if ( - isFirstButton && - prevPanelElLength > 0 && - !this.args.reverseCollapse && - prevPanel.collapsible - ) { - this.setSiblingPanelLengths( - prevPanel, - nextPanel, - 0, - prevPanelElLength + nextPanelElLength, - 0, - nextPanel.initialMinLengthPx, - ); - } else if (isFirstButton && prevPanelElLength <= 0) { - this.setSiblingPanelLengths( - prevPanel, - nextPanel, - prevPanel.defaultLengthFraction - ? panelGroupLengthPx * prevPanel.defaultLengthFraction - : prevPanel.lengthPx || 0, - prevPanel.defaultLengthFraction - ? nextPanelElLength - - panelGroupLengthPx * prevPanel.defaultLengthFraction - : panelGroupLengthPx - nextPanelElLength, - prevPanel.initialMinLengthPx, - nextPanel.initialMinLengthPx, - ); - } else if (isLastButton && nextPanelElLength > 0 && nextPanel.collapsible) { - this.setSiblingPanelLengths( - prevPanel, - nextPanel, - prevPanelElLength + nextPanelElLength, - 0, - prevPanel.initialMinLengthPx, - 0, - ); - } else if (isLastButton && nextPanelElLength <= 0) { - this.setSiblingPanelLengths( - prevPanel, - nextPanel, - nextPanel.defaultLengthFraction - ? prevPanelElLength - - panelGroupLengthPx * nextPanel.defaultLengthFraction - : panelGroupLengthPx - prevPanelElLength, - nextPanel.defaultLengthFraction - ? panelGroupLengthPx * nextPanel.defaultLengthFraction - : nextPanel.lengthPx || 0, - prevPanel.initialMinLengthPx, - nextPanel.initialMinLengthPx, - ); - } - - this.calculatePanelRatio(); - } - - @action - setSiblingPanelLengths( - prevPanel: ResizablePanel, - nextPanel: ResizablePanel, - newPrevLength: number, - newNextLength: number, - newPrevMinLength?: number, - newNextMinLength?: number, - ) { - if (prevPanel) { - prevPanel.lengthPx = newPrevLength; - prevPanel.minLengthPx = newPrevMinLength; - } - - if (nextPanel) { - nextPanel.lengthPx = newNextLength; - nextPanel.minLengthPx = newNextMinLength; - } - - this.args.onPanelChange?.(this.panels); - } - - @action - onContainerResize(entry?: ResizeObserverEntry, observer?: ResizeObserver) { - if (!this.panelGroupElement) { - if (entry) { - waiter.endAsync(this.initializationWaiter); - this.panelGroupElement = entry.target as HTMLDivElement; - next(this, this.onContainerResize, entry, observer); - } - return; - } - - this.hideHandles = - this.panelGroupElement[this.perpendicularLengthProperty] < - this.minimumLengthToShowHandles; - - let panelLengths: number[] = this.panels.map( - (panel) => panel.lengthPx || 0, - ); - let panelToNewLength = new Map(); + registerResizeHandle(handle: ResizeHandle) { + return { + startDragging: (event: ResizeEvent) => { + if (!this.element) { + return; + } + let dragHandleId = handle.id; + const initialCursorPosition = getResizeEventCursorPosition( + this.args.orientation, + event, + ); - let newContainerSize = this.panelGroupLengthWithoutResizeHandlePx; + this.dragState = { + dragHandleId, + dragHandleRect: handle.element.getBoundingClientRect(), + initialCursorPosition, + initialLayout: [...this.layout], + }; + }, + stopDragging: () => { + this.dragState = null; + }, + resizeHandler: (event: ResizeEvent) => { + event.preventDefault(); + let panelGroupElement = this.element; + if (!panelGroupElement || !this.dragState) { + return; + } - if (newContainerSize == undefined) { - console.warn('Expected newContainerSize to be defined'); - return; - } + let { initialLayout } = this.dragState; - let remainingContainerSize = newContainerSize; - let calculateLengthsOfPanelsWithMinLength = () => { - let panels = this.panels.filter((panel) => panel.initialMinLengthPx); + const pivotIndices = determinePivotIndices( + this.id, + handle.id, + panelGroupElement, + ); - panels.forEach((panel, index) => { - let panelRatio = panel.ratio; + let delta = calculateDeltaPercentage( + event, + handle.id, + this.args.orientation, + this.dragState, + panelGroupElement, + ); - if (!panelRatio || !newContainerSize) { + const panelConstraints = this.panels.map((panel) => panel.constraints); + const prevLayout = [...this.layout]; + const nextLayout = adjustLayoutByDelta({ + delta, + initialLayout: initialLayout ?? prevLayout, + panelConstraints, + pivotIndices, + prevLayout, + }); + const layoutChanged = !compareLayouts(prevLayout, nextLayout); + if (layoutChanged) { + this.updateLayout(nextLayout); + } + }, + // Double-click only works if the panel is either the first or last panel and is collapsible. + doubleClickHandler: (event: ResizeEvent) => { + event.preventDefault(); + let panelGroupElement = this.element; + if (!panelGroupElement) { return; } - let proportionalSize = panelRatio * newContainerSize; - - let actualSize = Math.round( - panel?.initialMinLengthPx - ? Math.max(proportionalSize, panel.initialMinLengthPx) - : proportionalSize, + const pivotIndices = determinePivotIndices( + this.id, + handle.id, + panelGroupElement, ); - panelLengths[index] = actualSize; - panelToNewLength.set(panel, actualSize); - remainingContainerSize = remainingContainerSize - actualSize; - }); - }; - - calculateLengthsOfPanelsWithMinLength(); - - let calculateLengthsOfPanelsWithoutMinLength = () => { - let panels = this.panels.filter((panel) => !panel.initialMinLengthPx); - let newPanelRatios = panels.map((panel) => panel.ratio ?? 0); - let totalNewPanelRatio = newPanelRatios.reduce( - (prevValue, currentValue) => prevValue + currentValue, - 0, - ); - newPanelRatios = newPanelRatios.map( - (panelRatio) => panelRatio / totalNewPanelRatio, - ); - - panels.forEach((panel, index) => { - let panelRatio = newPanelRatios[index]; - if (!panelRatio) { - console.warn('Expected panelRatio to be defined'); + if ( + pivotIndices[0] !== 0 && + pivotIndices[1] !== this.panels.length - 1 + ) { return; } - let proportionalSize = panelRatio * remainingContainerSize; - let actualSize = Math.round(proportionalSize); - - panelLengths[index] = actualSize; - panelToNewLength.set(panel, actualSize); - }); - }; - calculateLengthsOfPanelsWithoutMinLength(); - - this.panels.forEach((panel) => { - panel.lengthPx = panelToNewLength.get(panel) ?? 0; - }); - } - private findPanelsByResizeHandle(handle: ResizeHandle) { - let handleIndex = this.resizeHandles.indexOf(handle); - if (handleIndex === -1) { - return { - prevPanel: undefined, - nextPanel: undefined, - }; - } + let isFirstElement = + pivotIndices[0] === 0 && + !( + this.args.reverseCollapse && + pivotIndices[1] === this.panels.length - 1 + ); + let panel = isFirstElement + ? this.panels[0] + : this.panels[this.panels.length - 1]; + let panelSize = isFirstElement + ? this.layout[0] + : this.layout[this.panels.length - 1]; + if (!panel || panelSize == null) { + throw new Error('panel or panelSize is not found'); + } - let prevPanel = this.panels[handleIndex]; - let nextPanel = this.panels[handleIndex + 1]; + let delta; + if (panelSize <= 0) { + let panelSizeBeforeCollapse = this.panelSizeBeforeCollapse.get( + panel.id, + ); + if (panelSizeBeforeCollapse == null) { + throw new Error( + `panelSizeBeforeCollapse is not found for panel with id = ${panel.id}`, + ); + } + delta = isFirstElement + ? panelSizeBeforeCollapse + : 0 - panelSizeBeforeCollapse; + } else { + delta = isFirstElement ? 0 - panelSize : panelSize; + } - return { - prevPanel, - nextPanel, + const panelConstraints = this.panels.map((panel) => panel.constraints); + const prevLayout = [...this.layout]; + const nextLayout = adjustLayoutByDelta({ + delta, + initialLayout: prevLayout, + panelConstraints, + pivotIndices, + prevLayout, + }); + const layoutChanged = !compareLayouts(prevLayout, nextLayout); + if (layoutChanged) { + this.panelSizeBeforeCollapse.set(panel.id, panelSize); + this.updateLayout(nextLayout); + } + }, }; } - private isCursorInTheRightPlace(event: MouseEvent): boolean { - let { currentResizeHandle } = this; - if (!currentResizeHandle) { - return true; - } - - let { handle, prevPanel, nextPanel } = currentResizeHandle; - if (!handle || !prevPanel || !nextPanel) { - return true; + @action + calculateLayoutWhenPanelsChanged() { + if (!this.panelsChanged) { + return; } + this.panelsChanged = false; + let prevLayout = [...this.layout]; + let unsafeLayout = calculateUnsafeDefaultLayout({ + panels: this.panels, + }); + // Validate even saved layouts in case something has changed since last render + // e.g. for pixel groups, this could be the size of the window + const nextLayout = validatePanelGroupLayout({ + layout: unsafeLayout, + panelConstraints: this.panels.map((panel) => panel.constraints), + }); - let resizeHandleRect = handle.element.getBoundingClientRect(); - let rightCursorPosition = this.isHorizontal - ? (resizeHandleRect.left + resizeHandleRect.right) / 2 - : (resizeHandleRect.top + resizeHandleRect.bottom) / 2; - - let isCursorLeftOfHandle = - event[this.clientPositionProperty] <= Math.ceil(rightCursorPosition); - let isCursorRightOfHandle = - event[this.clientPositionProperty] >= Math.round(rightCursorPosition); - - let isPrevPanelAtMinLength = - !prevPanel.collapsible && - prevPanel.initialMinLengthPx === prevPanel.lengthPx; - let isNextPanelAtMinLength = - !nextPanel.collapsible && - nextPanel.initialMinLengthPx === nextPanel.lengthPx; - - if (isPrevPanelAtMinLength && isCursorLeftOfHandle) { - return false; + if (!compareLayouts(prevLayout, nextLayout)) { + this.updateLayout(nextLayout); } + } - if (isNextPanelAtMinLength && isCursorRightOfHandle) { - return false; - } + @action + updateLayout(nextLayout: number[]) { + this.layout.splice(0, this.layout.length); + nextLayout.forEach((layout) => this.layout.push(layout)); - return true; + this.args.onLayoutChange?.(this.layout); } } diff --git a/packages/boxel-ui/addon/src/components/resizable-panel-group/panel.gts b/packages/boxel-ui/addon/src/components/resizable-panel-group/panel.gts index 5da5106bd6..0c9e42790f 100644 --- a/packages/boxel-ui/addon/src/components/resizable-panel-group/panel.gts +++ b/packages/boxel-ui/addon/src/components/resizable-panel-group/panel.gts @@ -1,23 +1,26 @@ -import { registerDestructor } from '@ember/destroyable'; import { action } from '@ember/object'; -import { scheduleOnce } from '@ember/runloop'; +import { guidFor } from '@ember/object/internals'; import { htmlSafe } from '@ember/template'; import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; import { modifier } from 'ember-modifier'; -import cssVars from '../../helpers/css-var.ts'; -import { eq } from '../../helpers/truth-helpers.ts'; +import { + type GetPanelStyle, + type Orientation, + type ResizablePanelConstraints, +} from './utils/types.ts'; interface Signature { Args: { - collapsible?: boolean; //default true - defaultLengthFraction: number; - isHidden?: boolean; //default false - lengthPx?: number; - minLengthPx?: number; - orientation: 'horizontal' | 'vertical'; - registerPanel: (panel: Panel) => void; + collapsible?: boolean | undefined; + defaultSize?: number | undefined; + groupId: string; + maxSize?: number | undefined; + //In percentage + minSize?: number | undefined; + orientation: Orientation; + registerPanel: (panel: Panel) => GetPanelStyle; unregisterPanel: (panel: Panel) => void; }; Blocks: { @@ -28,82 +31,33 @@ interface Signature { let managePanelRegistration = modifier((element, [panel]: [Panel]) => { panel.element = element as HTMLDivElement; - scheduleOnce('afterRender', panel, panel.registerPanel); + panel.registerPanel(); + return () => { + panel.unregisterPanel(); + }; }); export default class Panel extends Component { element!: HTMLDivElement; - - @tracked lengthPx: number | undefined = 0; - @tracked minLengthPx: number | undefined = 0; - @tracked ratio: number | undefined; - initialMinLengthPx: number; - - @tracked collapsible: boolean; - - constructor(owner: any, args: Signature['Args']) { - super(owner, args); - this.lengthPx = args.lengthPx; - this.minLengthPx = args.minLengthPx || 0; - this.collapsible = args.collapsible ?? true; - this.initialMinLengthPx = this.args.minLengthPx || 0; - - registerDestructor(this, this.unregisterPanel); - } - - get isHidden() { - return this.args.isHidden; - } - - get defaultLengthFraction() { - return this.args.defaultLengthFraction; - } + @tracked private getStyle: GetPanelStyle = () => htmlSafe(''); + private _id = guidFor(this); @action registerPanel() { - this.args.registerPanel(this); + this.getStyle = this.args.registerPanel(this); } @action @@ -111,28 +65,17 @@ export default class Panel extends Component { this.args.unregisterPanel(this); } - get minLengthCssValue() { - if (this.args.isHidden) { - return htmlSafe('0px'); - } else if (this.minLengthPx !== undefined) { - return htmlSafe(`${this.minLengthPx}px`); - } else if (this.args.minLengthPx !== undefined) { - return htmlSafe(`${this.args.minLengthPx}px`); - } - return undefined; + get constraints(): ResizablePanelConstraints { + return { + collapsible: + this.args.collapsible == undefined ? true : this.args.collapsible, + defaultSize: this.args.defaultSize, + minSize: this.args.minSize, + maxSize: this.args.maxSize, + }; } - get lengthCssValue() { - let lengthPx = this.lengthPx; - let defaultLengthFraction = this.args.defaultLengthFraction; - - if (this.args.isHidden) { - return htmlSafe('0px'); - } else if (lengthPx === -1 && defaultLengthFraction) { - return htmlSafe(`${defaultLengthFraction * 100}%`); - } else if (lengthPx !== -1 && lengthPx !== undefined) { - return htmlSafe(`${lengthPx}px`); - } - return undefined; + get id() { + return this._id; } } diff --git a/packages/boxel-ui/addon/src/components/resizable-panel-group/usage.gts b/packages/boxel-ui/addon/src/components/resizable-panel-group/usage.gts index b8d861178d..8da3be9406 100644 --- a/packages/boxel-ui/addon/src/components/resizable-panel-group/usage.gts +++ b/packages/boxel-ui/addon/src/components/resizable-panel-group/usage.gts @@ -7,27 +7,38 @@ import { cssVariable, } from 'ember-freestyle/decorators/css-variable'; +import { not } from '../../helpers.ts'; import cssVar from '../../helpers/css-var.ts'; import ResizablePanelGroup from './index.gts'; export default class ResizablePanelUsage extends Component { - @tracked horizontalPanel1DefaultWidthFraction = 0.25; - @tracked horizontalPanel1MinWidthPx = undefined; + @tracked horizontalPanel1DefaultSize = 25; + @tracked horizontalPanel1MinSize = undefined; + @tracked horizontalPanel1MaxSize = undefined; + @tracked horizontalPanel1Collapsible = true; - @tracked horizontalPanel2DefaultWidthFraction = 0.5; - @tracked horizontalPanel2MinWidthPx = undefined; + @tracked horizontalPanel2DefaultSize = 50; + @tracked horizontalPanel2MinSize = undefined; + @tracked horizontalPanel2MaxSize = undefined; + @tracked horizontalPanel2Collapsible = true; - @tracked horizontalPanel3DefaultWidthFraction = 0.25; - @tracked horizontalPanel3MinWidthPx = undefined; + @tracked horizontalPanel3DefaultSize = 25; + @tracked horizontalPanel3MinSize = undefined; + @tracked horizontalPanel3MaxSize = undefined; + @tracked horizontalPanel3Collapsible = true; @tracked horizontalPanel3IsHidden = false; @tracked verticalReverseCollapse = true; - @tracked verticalPanel1DefaultHeightFraction = 0.33; - @tracked verticalPanel1MinHeightPx = undefined; + @tracked verticalPanel1DefaultSize = 33; + @tracked verticalPanel1MinSize = undefined; + @tracked verticalPanel1MaxSize = undefined; + @tracked verticalPanel1Collapsible = true; - @tracked verticalPanel2DefaultHeightFraction = 0.67; - @tracked verticalPanel2MinHeightPx = undefined; + @tracked verticalPanel2DefaultSize = 67; + @tracked verticalPanel2MinSize = undefined; + @tracked verticalPanel2MaxSize = undefined; + @tracked verticalPanel2Collapsible = true; cssClassName = 'boxel-panel'; @cssVariable declare boxelPanelResizeHandleHeight: CSSVariableInfo; @@ -49,69 +60,118 @@ export default class ResizablePanelUsage extends Component { as |ResizablePanel ResizeHandle| > Panel 1 Panel 2 - - - Panel 3 - + {{#if (not this.horizontalPanel3IsHidden)}} + + + Panel 3 + + {{/if}} <:api as |Args|> - + + - - - + + - + + Panel 1 Panel 2 @@ -188,31 +252,59 @@ export default class ResizablePanelUsage extends Component { @onInput={{fn (mut this.verticalReverseCollapse)}} /> + + + + diff --git a/packages/boxel-ui/addon/src/components/resizable-panel-group/utils/adjust-layout-by-delta.ts b/packages/boxel-ui/addon/src/components/resizable-panel-group/utils/adjust-layout-by-delta.ts new file mode 100644 index 0000000000..d71250e184 --- /dev/null +++ b/packages/boxel-ui/addon/src/components/resizable-panel-group/utils/adjust-layout-by-delta.ts @@ -0,0 +1,231 @@ +import { fuzzyLayoutsEqual } from './fuzzy-layouts-equal.ts'; +import { fuzzyCompareNumbers, fuzzyNumbersEqual } from './fuzzy-numbers.ts'; +import { resizePanel } from './resize-panel.ts'; +import type { ResizablePanelConstraints } from './types.ts'; + +export function adjustLayoutByDelta({ + delta, + initialLayout, + panelConstraints: panelConstraintsArray, + pivotIndices, + prevLayout, +}: { + delta: number; + initialLayout: number[]; + panelConstraints: ResizablePanelConstraints[]; + pivotIndices: number[]; + prevLayout: number[]; +}): number[] { + if (fuzzyNumbersEqual(delta, 0)) { + return initialLayout; + } + + const nextLayout = [...initialLayout]; + + const [firstPivotIndex, secondPivotIndex] = pivotIndices; + if (firstPivotIndex == null || secondPivotIndex == null) { + throw new Error('invalid pivot index'); + } + + let deltaApplied = 0; + + // A resizing panel affects the panels before or after it. + // + // A negative delta means the panel(s) immediately after the resize handle should grow/expand by decreasing its offset. + // Other panels may also need to shrink/contract (and shift) to make room, depending on the min weights. + // + // A positive delta means the panel(s) immediately before the resize handle should "expand". + // This is accomplished by shrinking/contracting (and shifting) one or more of the panels after the resize handle. + + { + // Pre-calculate max available delta in the opposite direction of our pivot. + // This will be the maximum amount we're allowed to expand/contract the panels in the primary direction. + // If this amount is less than the requested delta, adjust the requested delta. + // If this amount is greater than the requested delta, that's useful information too– + // as an expanding panel might change from collapsed to min size. + + const increment = delta < 0 ? 1 : -1; + + let index = delta < 0 ? secondPivotIndex : firstPivotIndex; + let maxAvailableDelta = 0; + + // DEBUG.push("pre calc..."); + // eslint-disable-next-line no-constant-condition + while (true) { + const prevSize = initialLayout[index]; + if (prevSize == null) { + throw new Error(`Previous layout not found for panel index ${index}`); + } + + const maxSafeSize = resizePanel({ + panelConstraints: panelConstraintsArray, + panelIndex: index, + size: 100, + }); + const delta = maxSafeSize - prevSize; + maxAvailableDelta += delta; + index += increment; + + if (index < 0 || index >= panelConstraintsArray.length) { + break; + } + } + + const minAbsDelta = Math.min(Math.abs(delta), Math.abs(maxAvailableDelta)); + delta = delta < 0 ? 0 - minAbsDelta : minAbsDelta; + } + + { + // WORKAROUND: For a collapsed panel, we need to adjust the delta to the minimum size. + // TODO: Address the following inconsistent behaviors: + // - When dragging outward on a collapsed panel, it opens the panel, but dragging inward does not collapse it again. + // The behavior should match that of dragging an opened panel where the cursor delta reaches the panel's minimum size. + // - When dragging an opened panel inward and the delta reaches the panel's minimum size, the panel collapses. + // However, if you continue dragging outward, it does not reopen as it does in the first scenario. + const pivotIndex = delta < 0 ? secondPivotIndex : firstPivotIndex; + + const initSize = initialLayout[pivotIndex]; + if (initSize == null) { + throw new Error( + `Previous layout not found for panel index ${pivotIndex}`, + ); + } + + const prevSize = prevLayout[pivotIndex]; + if (prevSize == null) { + throw new Error( + `Previous layout not found for panel index ${pivotIndex}`, + ); + } + + let minSize = panelConstraintsArray[pivotIndex]?.minSize; + if ( + (initSize == 0 || prevSize == 0) && + minSize != null && + fuzzyCompareNumbers(Math.abs(delta), minSize) < 0 + ) { + delta = delta >= 0 ? minSize : 0 - minSize; + } + } + + { + // Delta added to a panel needs to be subtracted from other panels (within the constraints that those panels allow). + + const pivotIndex = delta < 0 ? firstPivotIndex : secondPivotIndex; + let index = pivotIndex; + while (index >= 0 && index < panelConstraintsArray.length) { + const deltaRemaining = Math.abs(delta) - Math.abs(deltaApplied); + + const prevSize = initialLayout[index]; + if (prevSize == null) { + throw new Error(`Previous layout not found for panel index ${index}`); + } + + const unsafeSize = prevSize - deltaRemaining; + const safeSize = resizePanel({ + panelConstraints: panelConstraintsArray, + panelIndex: index, + size: unsafeSize, + }); + + if (!fuzzyNumbersEqual(prevSize, safeSize)) { + deltaApplied += prevSize - safeSize; + + nextLayout[index] = safeSize; + + if ( + deltaApplied + .toPrecision(3) + .localeCompare(Math.abs(delta).toPrecision(3), undefined, { + numeric: true, + }) >= 0 + ) { + break; + } + } + + if (delta < 0) { + index--; + } else { + index++; + } + } + } + + // If we were unable to resize any of the panels panels, return the previous state. + // This will essentially bailout and ignore e.g. drags past a panel's boundaries + if (fuzzyLayoutsEqual(prevLayout, nextLayout)) { + return prevLayout; + } + + { + // Now distribute the applied delta to the panels in the other direction + const pivotIndex = delta < 0 ? secondPivotIndex : firstPivotIndex; + + const prevSize = initialLayout[pivotIndex]; + if (prevSize == null) { + throw new Error( + `Previous layout not found for panel index ${pivotIndex}`, + ); + } + + const unsafeSize = prevSize + deltaApplied; + const safeSize = resizePanel({ + panelConstraints: panelConstraintsArray, + panelIndex: pivotIndex, + size: unsafeSize, + }); + + // Adjust the pivot panel before, but only by the amount that surrounding panels were able to shrink/contract. + nextLayout[pivotIndex] = safeSize; + + // Edge case where expanding or contracting one panel caused another one to change collapsed state + if (!fuzzyNumbersEqual(safeSize, unsafeSize)) { + let deltaRemaining = unsafeSize - safeSize; + + const pivotIndex = delta < 0 ? secondPivotIndex : firstPivotIndex; + let index = pivotIndex; + while (index >= 0 && index < panelConstraintsArray.length) { + const prevSize = nextLayout[index]; + if (prevSize == null) { + throw new Error( + `Previous layout not found for panel index ${pivotIndex}`, + ); + } + + const unsafeSize = prevSize + deltaRemaining; + const safeSize = resizePanel({ + panelConstraints: panelConstraintsArray, + panelIndex: index, + size: unsafeSize, + }); + + if (!fuzzyNumbersEqual(prevSize, safeSize)) { + deltaRemaining -= safeSize - prevSize; + + nextLayout[index] = safeSize; + } + + if (fuzzyNumbersEqual(deltaRemaining, 0)) { + break; + } + + if (delta > 0) { + index--; + } else { + index++; + } + } + } + } + + const totalSize = nextLayout.reduce((total, size) => size + total, 0); + + // If our new layout doesn't add up to 100%, that means the requested delta can't be applied + // In that case, fall back to our most recent valid layout + if (!fuzzyNumbersEqual(totalSize, 100)) { + return prevLayout; + } + + return nextLayout; +} diff --git a/packages/boxel-ui/addon/src/components/resizable-panel-group/utils/assert.ts b/packages/boxel-ui/addon/src/components/resizable-panel-group/utils/assert.ts new file mode 100644 index 0000000000..9f7d03bc24 --- /dev/null +++ b/packages/boxel-ui/addon/src/components/resizable-panel-group/utils/assert.ts @@ -0,0 +1,10 @@ +export function assert( + expectedCondition: any, + message: string, +): asserts expectedCondition { + if (!expectedCondition) { + console.error(message); + + throw Error(message); + } +} diff --git a/packages/boxel-ui/addon/src/components/resizable-panel-group/utils/calculate-delta-percentage.ts b/packages/boxel-ui/addon/src/components/resizable-panel-group/utils/calculate-delta-percentage.ts new file mode 100644 index 0000000000..fa72adab87 --- /dev/null +++ b/packages/boxel-ui/addon/src/components/resizable-panel-group/utils/calculate-delta-percentage.ts @@ -0,0 +1,41 @@ +import { getPanelGroupElement } from './dom/get-panel-group-element.ts'; +import { getResizeHandleElement } from './dom/get-resize-handle-element.ts'; +import { getResizeEventCursorPosition } from './get-resize-event-cursor-position.ts'; +import type { DragState, Orientation, ResizeEvent } from './types.ts'; + +export function calculateDeltaPercentage( + event: ResizeEvent, + dragHandleId: string, + orientation: Orientation, + initialDragState: DragState, + panelGroupElement: HTMLElement, +): number { + const isHorizontal = orientation === 'horizontal'; + + const handleElement = getResizeHandleElement(dragHandleId, panelGroupElement); + if (!handleElement) { + throw new Error(`No resize handle element found for id "${dragHandleId}"`); + } + + const groupId = handleElement.getAttribute('data-boxel-panel-group-id'); + if (!groupId) { + throw new Error(`Resize handle element has no group id attribute`); + } + + let { initialCursorPosition } = initialDragState; + + const cursorPosition = getResizeEventCursorPosition(orientation, event); + + const groupElement = getPanelGroupElement(groupId, panelGroupElement); + if (!groupElement) { + throw new Error(`No group element found for id "${groupId}"`); + } + + const groupRect = groupElement.getBoundingClientRect(); + const groupSizeInPixels = isHorizontal ? groupRect.width : groupRect.height; + + const offsetPixels = cursorPosition - initialCursorPosition; + const offsetPercentage = (offsetPixels / groupSizeInPixels) * 100; + + return offsetPercentage; +} diff --git a/packages/boxel-ui/addon/src/components/resizable-panel-group/utils/calculate-unsafe-default-layout.ts b/packages/boxel-ui/addon/src/components/resizable-panel-group/utils/calculate-unsafe-default-layout.ts new file mode 100644 index 0000000000..2affde3576 --- /dev/null +++ b/packages/boxel-ui/addon/src/components/resizable-panel-group/utils/calculate-unsafe-default-layout.ts @@ -0,0 +1,53 @@ +import type { ResizablePanelConstraints } from './types.ts'; + +export function calculateUnsafeDefaultLayout({ + panels, +}: { + panels: { + constraints: ResizablePanelConstraints; + }[]; +}): number[] { + const layout = Array(panels.length); + + const panelConstraintsArray = panels.map((panel) => panel.constraints); + + let numPanelsWithSizes = 0; + let remainingSize = 100; + + // Distribute default sizes first + for (let index = 0; index < panels.length; index++) { + const panelConstraints = panelConstraintsArray[index]; + if (!panelConstraints) { + throw new Error(`Panel constraints not found for index ${index}`); + } + const { defaultSize } = panelConstraints; + + if (defaultSize != null) { + numPanelsWithSizes++; + layout[index] = defaultSize; + remainingSize -= defaultSize; + } + } + + // Remaining size should be distributed evenly between panels without default sizes + for (let index = 0; index < panels.length; index++) { + const panelConstraints = panelConstraintsArray[index]; + if (!panelConstraints) { + throw new Error(`Panel constraints not found for index ${index}`); + } + const { defaultSize } = panelConstraints; + + if (defaultSize != null) { + continue; + } + + const numRemainingPanels = panels.length - numPanelsWithSizes; + const size = remainingSize / numRemainingPanels; + + numPanelsWithSizes++; + layout[index] = size; + remainingSize -= size; + } + + return layout; +} diff --git a/packages/boxel-ui/addon/src/components/resizable-panel-group/utils/compare-layouts.ts b/packages/boxel-ui/addon/src/components/resizable-panel-group/utils/compare-layouts.ts new file mode 100644 index 0000000000..04d5f1cbdb --- /dev/null +++ b/packages/boxel-ui/addon/src/components/resizable-panel-group/utils/compare-layouts.ts @@ -0,0 +1,12 @@ +export function compareLayouts(a: number[], b: number[]) { + if (a.length !== b.length) { + return false; + } else { + for (let index = 0; index < a.length; index++) { + if (a[index] != b[index]) { + return false; + } + } + } + return true; +} diff --git a/packages/boxel-ui/addon/src/components/resizable-panel-group/utils/const.ts b/packages/boxel-ui/addon/src/components/resizable-panel-group/utils/const.ts new file mode 100644 index 0000000000..bfa3b40992 --- /dev/null +++ b/packages/boxel-ui/addon/src/components/resizable-panel-group/utils/const.ts @@ -0,0 +1,6 @@ +export const PRECISION = 10; + +export const EXCEEDED_HORIZONTAL_MIN = 0b0001; +export const EXCEEDED_HORIZONTAL_MAX = 0b0010; +export const EXCEEDED_VERTICAL_MIN = 0b0100; +export const EXCEEDED_VERTICAL_MAX = 0b1000; diff --git a/packages/boxel-ui/addon/src/components/resizable-panel-group/utils/determine-pivot-indices.ts b/packages/boxel-ui/addon/src/components/resizable-panel-group/utils/determine-pivot-indices.ts new file mode 100644 index 0000000000..bb5596acc6 --- /dev/null +++ b/packages/boxel-ui/addon/src/components/resizable-panel-group/utils/determine-pivot-indices.ts @@ -0,0 +1,15 @@ +import { getResizeHandleElementIndex } from './dom/get-resize-handle-element-index.ts'; + +export function determinePivotIndices( + groupId: string, + dragHandleId: string, + panelGroupElement: ParentNode, +): [indexBefore: number, indexAfter: number] { + const index = getResizeHandleElementIndex( + groupId, + dragHandleId, + panelGroupElement, + ); + + return index != null ? [index, index + 1] : [-1, -1]; +} diff --git a/packages/boxel-ui/addon/src/components/resizable-panel-group/utils/dom/get-panel-element.ts b/packages/boxel-ui/addon/src/components/resizable-panel-group/utils/dom/get-panel-element.ts new file mode 100644 index 0000000000..15b8b05fc8 --- /dev/null +++ b/packages/boxel-ui/addon/src/components/resizable-panel-group/utils/dom/get-panel-element.ts @@ -0,0 +1,10 @@ +export function getPanelElement( + id: string, + scope: ParentNode | HTMLElement = document, +): HTMLElement | null { + const element = scope.querySelector(`[data-boxel-panel-id="${id}"]`); + if (element) { + return element as HTMLElement; + } + return null; +} diff --git a/packages/boxel-ui/addon/src/components/resizable-panel-group/utils/dom/get-panel-elements-for-group.ts b/packages/boxel-ui/addon/src/components/resizable-panel-group/utils/dom/get-panel-elements-for-group.ts new file mode 100644 index 0000000000..ed18456f75 --- /dev/null +++ b/packages/boxel-ui/addon/src/components/resizable-panel-group/utils/dom/get-panel-elements-for-group.ts @@ -0,0 +1,8 @@ +export function getPanelElementsForGroup( + groupId: string, + scope: ParentNode | HTMLElement = document, +): HTMLElement[] { + return Array.from( + scope.querySelectorAll(`[data-boxel-panel-group-id="${groupId}"]`), + ); +} diff --git a/packages/boxel-ui/addon/src/components/resizable-panel-group/utils/dom/get-panel-group-element.ts b/packages/boxel-ui/addon/src/components/resizable-panel-group/utils/dom/get-panel-group-element.ts new file mode 100644 index 0000000000..9b3a6f5f2f --- /dev/null +++ b/packages/boxel-ui/addon/src/components/resizable-panel-group/utils/dom/get-panel-group-element.ts @@ -0,0 +1,21 @@ +export function getPanelGroupElement( + id: string, + rootElement: ParentNode | HTMLElement = document, +): HTMLElement | null { + //If the root element is the PanelGroup + if ( + rootElement instanceof HTMLElement && + (rootElement as HTMLElement)?.hasAttribute('data-boxel-panel-group') + ) { + return rootElement as HTMLElement; + } + + //Else query children + const element = rootElement.querySelector( + `[data-boxel-panel-group][data-boxel-panel-group-id="${id}"]`, + ); + if (element) { + return element as HTMLElement; + } + return null; +} diff --git a/packages/boxel-ui/addon/src/components/resizable-panel-group/utils/dom/get-resize-handle-element-index.ts b/packages/boxel-ui/addon/src/components/resizable-panel-group/utils/dom/get-resize-handle-element-index.ts new file mode 100644 index 0000000000..b8a4dd559e --- /dev/null +++ b/packages/boxel-ui/addon/src/components/resizable-panel-group/utils/dom/get-resize-handle-element-index.ts @@ -0,0 +1,14 @@ +import { getResizeHandleElementsForGroup } from './get-resize-handle-elements-for-group.ts'; + +export function getResizeHandleElementIndex( + groupId: string, + id: string, + scope: ParentNode | HTMLElement = document, +): number | null { + const handles = getResizeHandleElementsForGroup(groupId, scope); + const index = handles.findIndex( + (handle: HTMLElement) => + handle.getAttribute('data-boxel-panel-resize-handle-id') === id, + ); + return index ?? null; +} diff --git a/packages/boxel-ui/addon/src/components/resizable-panel-group/utils/dom/get-resize-handle-element.ts b/packages/boxel-ui/addon/src/components/resizable-panel-group/utils/dom/get-resize-handle-element.ts new file mode 100644 index 0000000000..94b4483fb7 --- /dev/null +++ b/packages/boxel-ui/addon/src/components/resizable-panel-group/utils/dom/get-resize-handle-element.ts @@ -0,0 +1,12 @@ +export function getResizeHandleElement( + id: string, + scope: ParentNode | HTMLElement = document, +): HTMLElement | null { + const element = scope.querySelector( + `[data-boxel-panel-resize-handle-id="${id}"]`, + ); + if (element) { + return element as HTMLElement; + } + return null; +} diff --git a/packages/boxel-ui/addon/src/components/resizable-panel-group/utils/dom/get-resize-handle-elements-for-group.ts b/packages/boxel-ui/addon/src/components/resizable-panel-group/utils/dom/get-resize-handle-elements-for-group.ts new file mode 100644 index 0000000000..c0677689f8 --- /dev/null +++ b/packages/boxel-ui/addon/src/components/resizable-panel-group/utils/dom/get-resize-handle-elements-for-group.ts @@ -0,0 +1,10 @@ +export function getResizeHandleElementsForGroup( + groupId: string, + scope: ParentNode | HTMLElement = document, +): HTMLElement[] { + return Array.from( + scope.querySelectorAll( + `[data-boxel-panel-resize-handle-id][data-boxel-panel-group-id="${groupId}"]`, + ), + ); +} diff --git a/packages/boxel-ui/addon/src/components/resizable-panel-group/utils/fuzzy-layouts-equal.ts b/packages/boxel-ui/addon/src/components/resizable-panel-group/utils/fuzzy-layouts-equal.ts new file mode 100644 index 0000000000..188cfe67c4 --- /dev/null +++ b/packages/boxel-ui/addon/src/components/resizable-panel-group/utils/fuzzy-layouts-equal.ts @@ -0,0 +1,22 @@ +import { fuzzyNumbersEqual } from './fuzzy-numbers.ts'; + +export function fuzzyLayoutsEqual( + actual: number[], + expected: number[], + fractionDigits?: number, +): boolean { + if (actual.length !== expected.length) { + return false; + } + + for (let index = 0; index < actual.length; index++) { + const actualSize = actual[index] as number; + const expectedSize = expected[index] as number; + + if (!fuzzyNumbersEqual(actualSize, expectedSize, fractionDigits)) { + return false; + } + } + + return true; +} diff --git a/packages/boxel-ui/addon/src/components/resizable-panel-group/utils/fuzzy-numbers.ts b/packages/boxel-ui/addon/src/components/resizable-panel-group/utils/fuzzy-numbers.ts new file mode 100644 index 0000000000..68e4994c70 --- /dev/null +++ b/packages/boxel-ui/addon/src/components/resizable-panel-group/utils/fuzzy-numbers.ts @@ -0,0 +1,21 @@ +import { PRECISION } from './const.ts'; + +export function fuzzyNumbersEqual( + actual: number, + expected: number, + fractionDigits: number = PRECISION, +): boolean { + return fuzzyCompareNumbers(actual, expected, fractionDigits) === 0; +} + +export function fuzzyCompareNumbers( + actual: number, + expected: number, + fractionDigits: number = PRECISION, +): number { + if (actual.toFixed(fractionDigits) === expected.toFixed(fractionDigits)) { + return 0; + } else { + return actual > expected ? 1 : -1; + } +} diff --git a/packages/boxel-ui/addon/src/components/resizable-panel-group/utils/get-resize-event-coordinates.ts b/packages/boxel-ui/addon/src/components/resizable-panel-group/utils/get-resize-event-coordinates.ts new file mode 100644 index 0000000000..70da6c9c29 --- /dev/null +++ b/packages/boxel-ui/addon/src/components/resizable-panel-group/utils/get-resize-event-coordinates.ts @@ -0,0 +1,8 @@ +import type { ResizeEvent } from './types.ts'; + +export function getResizeEventCoordinates(event: ResizeEvent) { + return { + x: event.clientX, + y: event.clientY, + }; +} diff --git a/packages/boxel-ui/addon/src/components/resizable-panel-group/utils/get-resize-event-cursor-position.ts b/packages/boxel-ui/addon/src/components/resizable-panel-group/utils/get-resize-event-cursor-position.ts new file mode 100644 index 0000000000..0d9b955960 --- /dev/null +++ b/packages/boxel-ui/addon/src/components/resizable-panel-group/utils/get-resize-event-cursor-position.ts @@ -0,0 +1,10 @@ +import type { Orientation, ResizeEvent } from './types.ts'; + +export function getResizeEventCursorPosition( + orientation: Orientation, + event: ResizeEvent, +): number { + const isHorizontal = orientation === 'horizontal'; + + return isHorizontal ? event.clientX : event.clientY; +} diff --git a/packages/boxel-ui/addon/src/components/resizable-panel-group/utils/panel-resize-handle-registry.ts b/packages/boxel-ui/addon/src/components/resizable-panel-group/utils/panel-resize-handle-registry.ts new file mode 100644 index 0000000000..4e21fda56e --- /dev/null +++ b/packages/boxel-ui/addon/src/components/resizable-panel-group/utils/panel-resize-handle-registry.ts @@ -0,0 +1,441 @@ +import { + EXCEEDED_HORIZONTAL_MAX, + EXCEEDED_HORIZONTAL_MIN, + EXCEEDED_VERTICAL_MAX, + EXCEEDED_VERTICAL_MIN, +} from './const.ts'; +import { getResizeEventCoordinates } from './get-resize-event-coordinates.ts'; +import type { Orientation, ResizeEvent } from './types.ts'; + +export type ResizeHandlerAction = 'down' | 'move' | 'up' | 'dblclick'; +export type SetResizeHandlerState = ( + action: ResizeHandlerAction, + isActive: boolean, + event: ResizeEvent, +) => void; + +export type PointerHitAreaMargins = { + coarse: number; + fine: number; +}; + +export type ResizeHandlerData = { + element: HTMLElement; + hitAreaMargins: PointerHitAreaMargins; + orientation: Orientation; + setResizeHandlerState: SetResizeHandlerState; +}; + +const isCoarsePointer = getInputType() === 'coarse'; + +let intersectingHandles: ResizeHandlerData[] = []; +let isPointerDown = false; +let ownerDocumentCounts: Map = new Map(); +let panelConstraintFlags: Map = new Map(); + +const registeredResizeHandlers = new Set(); + +export function registerResizeHandle( + resizeHandleId: string, + element: HTMLElement, + orientation: Orientation, + hitAreaMargins: PointerHitAreaMargins, + setResizeHandlerState: SetResizeHandlerState, +) { + const { ownerDocument } = element; + + const data: ResizeHandlerData = { + orientation, + element, + hitAreaMargins, + setResizeHandlerState, + }; + + const count = ownerDocumentCounts.get(ownerDocument) ?? 0; + ownerDocumentCounts.set(ownerDocument, count + 1); + + registeredResizeHandlers.add(data); + + updateListeners(); + + return function unregisterResizeHandle() { + panelConstraintFlags.delete(resizeHandleId); + registeredResizeHandlers.delete(data); + + const count = ownerDocumentCounts.get(ownerDocument) ?? 1; + ownerDocumentCounts.set(ownerDocument, count - 1); + + updateListeners(); + + if (count === 1) { + ownerDocumentCounts.delete(ownerDocument); + } + + // If the resize handle that is currently unmounting is intersecting with the pointer, + // update the global pointer to account for the change + if (intersectingHandles.includes(data)) { + const index = intersectingHandles.indexOf(data); + if (index >= 0) { + intersectingHandles.splice(index, 1); + } + + updateCursor(); + } + }; +} + +function handleDoubleClick(event: ResizeEvent) { + const { target } = event; + const { x, y } = getResizeEventCoordinates(event); + + recalculateIntersectingHandles({ target, x, y }); + updateListeners(); + + if (intersectingHandles.length > 0) { + updateResizeHandlerStates('dblclick', event); + + event.preventDefault(); + event.stopPropagation(); + } +} + +function handlePointerDown(event: ResizeEvent) { + const { target } = event; + const { x, y } = getResizeEventCoordinates(event); + + isPointerDown = true; + + recalculateIntersectingHandles({ target, x, y }); + updateListeners(); + + if (intersectingHandles.length > 0) { + updateResizeHandlerStates('down', event); + + event.preventDefault(); + event.stopPropagation(); + } +} + +function handlePointerMove(event: ResizeEvent) { + const { x, y } = getResizeEventCoordinates(event); + + if (!isPointerDown) { + const { target } = event; + + // Recalculate intersecting handles whenever the pointer moves, except if it has already been pressed + // at that point, the handles may not move with the pointer (depending on constraints) + // but the same set of active handles should be locked until the pointer is released + recalculateIntersectingHandles({ target, x, y }); + } + + updateResizeHandlerStates('move', event); + + // Update cursor based on return value(s) from active handles + updateCursor(); + + if (intersectingHandles.length > 0) { + event.preventDefault(); + } +} + +function handlePointerUp(event: ResizeEvent) { + const { target } = event; + const { x, y } = getResizeEventCoordinates(event); + + panelConstraintFlags.clear(); + isPointerDown = false; + + if (intersectingHandles.length > 0) { + event.preventDefault(); + } + + updateResizeHandlerStates('up', event); + recalculateIntersectingHandles({ target, x, y }); + updateCursor(); + + updateListeners(); +} + +function recalculateIntersectingHandles({ + target, + x, + y, +}: { + target: EventTarget | null; + x: number; + y: number; +}) { + intersectingHandles.splice(0); + + let targetElement: HTMLElement | null = null; + if (target instanceof HTMLElement) { + targetElement = target; + } + + registeredResizeHandlers.forEach((data) => { + const { element: dragHandleElement, hitAreaMargins } = data; + + const dragHandleRect = dragHandleElement.getBoundingClientRect(); + const { bottom, left, right, top } = dragHandleRect; + + const margin = isCoarsePointer + ? hitAreaMargins.coarse + : hitAreaMargins.fine; + + const eventIntersects = + x >= left - margin && + x <= right + margin && + y >= top - margin && + y <= bottom + margin; + + if (eventIntersects) { + // TRICKY + // We listen for pointers events at the root in order to support hit area margins + // (determining when the pointer is close enough to an element to be considered a 'hit') + // Clicking on an element 'above' a handle (e.g. a modal) should prevent a hit though + // so at this point we need to compare stacking order of a potentially intersecting drag handle, + // and the element that was actually clicked/touched + if ( + targetElement !== null && + dragHandleElement !== targetElement && + !dragHandleElement.contains(targetElement) && + !targetElement.contains(dragHandleElement) + ) { + // If the target is above the drag handle, then we also need to confirm they overlap + // If they are beside each other (e.g. a panel and its drag handle) then the handle is still interactive + // + // It's not enough to compare only the target + // The target might be a small element inside of a larger container + // (For example, a SPAN or a DIV inside of a larger modal dialog) + let currentElement: HTMLElement | null = targetElement; + let didIntersect = false; + while (currentElement) { + if (currentElement.contains(dragHandleElement)) { + break; + } else if ( + intersects( + currentElement.getBoundingClientRect(), + dragHandleRect, + true, + ) + ) { + didIntersect = true; + break; + } + + currentElement = currentElement.parentElement; + } + + if (didIntersect) { + return; + } + } + + intersectingHandles.push(data); + } + }); +} + +export function reportConstraintsViolation( + resizeHandleId: string, + flag: number, +) { + panelConstraintFlags.set(resizeHandleId, flag); +} + +function updateCursor() { + let intersectsHorizontal = false; + let intersectsVertical = false; + + intersectingHandles.forEach((data) => { + const { orientation } = data; + + if (orientation === 'horizontal') { + intersectsHorizontal = true; + } else { + intersectsVertical = true; + } + }); + + let constraintFlags = 0; + panelConstraintFlags.forEach((flag) => { + constraintFlags |= flag; + }); + + if (intersectsHorizontal && intersectsVertical) { + setGlobalCursorStyle('intersection', constraintFlags); + } else if (intersectsHorizontal) { + setGlobalCursorStyle('horizontal', constraintFlags); + } else if (intersectsVertical) { + setGlobalCursorStyle('vertical', constraintFlags); + } else { + resetGlobalCursorStyle(); + } +} + +function updateListeners() { + ownerDocumentCounts.forEach((_, ownerDocument) => { + const { body } = ownerDocument; + + body.removeEventListener('contextmenu', handlePointerUp); + body.removeEventListener('pointerdown', handlePointerDown); + body.removeEventListener('pointerleave', handlePointerMove); + body.removeEventListener('pointermove', handlePointerMove); + body.removeEventListener('dblclick', handleDoubleClick); + }); + + window.removeEventListener('pointerup', handlePointerUp); + window.removeEventListener('pointercancel', handlePointerUp); + + if (registeredResizeHandlers.size > 0) { + if (isPointerDown) { + if (intersectingHandles.length > 0) { + ownerDocumentCounts.forEach((count, ownerDocument) => { + const { body } = ownerDocument; + + if (count > 0) { + body.addEventListener('contextmenu', handlePointerUp); + body.addEventListener('pointerleave', handlePointerMove); + body.addEventListener('pointermove', handlePointerMove); + } + }); + } + + window.addEventListener('pointerup', handlePointerUp); + window.addEventListener('pointercancel', handlePointerUp); + } else { + ownerDocumentCounts.forEach((count, ownerDocument) => { + const { body } = ownerDocument; + + if (count > 0) { + body.addEventListener('dblclick', handleDoubleClick); + body.addEventListener('pointerdown', handlePointerDown, { + capture: true, + }); + body.addEventListener('pointermove', handlePointerMove); + } + }); + } + } +} + +function updateResizeHandlerStates( + action: ResizeHandlerAction, + event: ResizeEvent, +) { + registeredResizeHandlers.forEach((data) => { + const { setResizeHandlerState } = data; + + const isActive = intersectingHandles.includes(data); + + setResizeHandlerState(action, isActive, event); + }); +} + +interface Rectangle { + height: number; + width: number; + x: number; + y: number; +} + +function intersects( + rectOne: Rectangle, + rectTwo: Rectangle, + strict: boolean, +): boolean { + if (strict) { + return ( + rectOne.x < rectTwo.x + rectTwo.width && + rectOne.x + rectOne.width > rectTwo.x && + rectOne.y < rectTwo.y + rectTwo.height && + rectOne.y + rectOne.height > rectTwo.y + ); + } else { + return ( + rectOne.x <= rectTwo.x + rectTwo.width && + rectOne.x + rectOne.width >= rectTwo.x && + rectOne.y <= rectTwo.y + rectTwo.height && + rectOne.y + rectOne.height >= rectTwo.y + ); + } +} + +function getInputType(): 'coarse' | 'fine' | undefined { + if (typeof matchMedia === 'function') { + return matchMedia('(pointer:coarse)').matches ? 'coarse' : 'fine'; + } + return undefined; +} + +type CursorState = 'horizontal' | 'intersection' | 'vertical'; + +let currentCursorStyle: string | null = null; +let styleElement: HTMLStyleElement | null = null; + +function getCursorStyle(state: CursorState, constraintFlags: number): string { + if (constraintFlags) { + const horizontalMin = (constraintFlags & EXCEEDED_HORIZONTAL_MIN) !== 0; + const horizontalMax = (constraintFlags & EXCEEDED_HORIZONTAL_MAX) !== 0; + const verticalMin = (constraintFlags & EXCEEDED_VERTICAL_MIN) !== 0; + const verticalMax = (constraintFlags & EXCEEDED_VERTICAL_MAX) !== 0; + + if (horizontalMin) { + if (verticalMin) { + return 'se-resize'; + } else if (verticalMax) { + return 'ne-resize'; + } else { + return 'e-resize'; + } + } else if (horizontalMax) { + if (verticalMin) { + return 'sw-resize'; + } else if (verticalMax) { + return 'nw-resize'; + } else { + return 'w-resize'; + } + } else if (verticalMin) { + return 's-resize'; + } else if (verticalMax) { + return 'n-resize'; + } + } + + switch (state) { + case 'horizontal': + return 'ew-resize'; + case 'intersection': + return 'move'; + case 'vertical': + return 'ns-resize'; + } +} + +function resetGlobalCursorStyle() { + if (styleElement !== null) { + document.head.removeChild(styleElement); + + currentCursorStyle = null; + styleElement = null; + } +} + +function setGlobalCursorStyle(state: CursorState, constraintFlags: number) { + const style = getCursorStyle(state, constraintFlags); + + if (currentCursorStyle === style) { + return; + } + + currentCursorStyle = style; + + if (styleElement === null) { + styleElement = document.createElement('style'); + + document.head.appendChild(styleElement); + } + + styleElement.innerHTML = `*{cursor: ${style}!important;}`; +} diff --git a/packages/boxel-ui/addon/src/components/resizable-panel-group/utils/resize-panel.ts b/packages/boxel-ui/addon/src/components/resizable-panel-group/utils/resize-panel.ts new file mode 100644 index 0000000000..5595e4dd74 --- /dev/null +++ b/packages/boxel-ui/addon/src/components/resizable-panel-group/utils/resize-panel.ts @@ -0,0 +1,35 @@ +import { assert } from './assert.ts'; +import { PRECISION } from './const.ts'; +import { fuzzyCompareNumbers } from './fuzzy-numbers.ts'; +import type { ResizablePanelConstraints } from './types.ts'; + +export function resizePanel({ + panelConstraints: panelConstraintsArray, + panelIndex, + size, +}: { + panelConstraints: ResizablePanelConstraints[]; + panelIndex: number; + size: number; +}) { + const panelConstraints = panelConstraintsArray[panelIndex]; + assert( + panelConstraints != null, + `Panel constraints not found for index ${panelIndex}`, + ); + + let { collapsible, maxSize = 100, minSize = 0 } = panelConstraints; + + if (fuzzyCompareNumbers(size, minSize) < 0) { + if (collapsible) { + size = 0; + } else { + size = minSize; + } + } + + size = Math.min(maxSize, size); + size = parseFloat(size.toFixed(PRECISION)); + + return size; +} diff --git a/packages/boxel-ui/addon/src/components/resizable-panel-group/utils/types.ts b/packages/boxel-ui/addon/src/components/resizable-panel-group/utils/types.ts new file mode 100644 index 0000000000..2eb0c36a1f --- /dev/null +++ b/packages/boxel-ui/addon/src/components/resizable-panel-group/utils/types.ts @@ -0,0 +1,23 @@ +import type { SafeString } from '@ember/template'; + +export type GetPanelStyle = () => SafeString; + +export type DragState = { + dragHandleId: string; + dragHandleRect: DOMRect; + initialCursorPosition: number; + initialLayout: number[]; +}; + +export type ResizeHandleState = 'drag' | 'hover' | 'inactive'; +export type ResizeEvent = PointerEvent | MouseEvent; +export type ResizeHandler = (event: ResizeEvent) => void; + +export type Orientation = 'horizontal' | 'vertical'; + +export type ResizablePanelConstraints = { + collapsible?: boolean | undefined; + defaultSize?: number | undefined; + maxSize?: number | undefined; + minSize?: number | undefined; +}; diff --git a/packages/boxel-ui/addon/src/components/resizable-panel-group/utils/validate-panel-group-layout.ts b/packages/boxel-ui/addon/src/components/resizable-panel-group/utils/validate-panel-group-layout.ts new file mode 100644 index 0000000000..0b2ee65c34 --- /dev/null +++ b/packages/boxel-ui/addon/src/components/resizable-panel-group/utils/validate-panel-group-layout.ts @@ -0,0 +1,86 @@ +import { fuzzyNumbersEqual } from './fuzzy-numbers.ts'; +import { resizePanel } from './resize-panel.ts'; +import type { ResizablePanelConstraints } from './types.ts'; + +export function validatePanelGroupLayout({ + layout: prevLayout, + panelConstraints, +}: { + layout: number[]; + panelConstraints: ResizablePanelConstraints[]; +}): number[] { + const nextLayout = [...prevLayout]; + const nextLayoutTotalSize = nextLayout.reduce( + (accumulated, current) => accumulated + current, + 0, + ); + + // Validate layout expectations + if (nextLayout.length !== panelConstraints.length) { + throw Error( + `Invalid ${panelConstraints.length} panel layout: ${nextLayout + .map((size) => `${size}%`) + .join(', ')}`, + ); + } else if (!fuzzyNumbersEqual(nextLayoutTotalSize, 100)) { + for (let index = 0; index < panelConstraints.length; index++) { + const unsafeSize = nextLayout[index]; + if (unsafeSize == null) { + throw new Error(`No layout data found for index ${index}`); + } + const safeSize = (100 / nextLayoutTotalSize) * unsafeSize; + nextLayout[index] = safeSize; + } + } + + let remainingSize = 0; + + // First pass: Validate the proposed layout given each panel's constraints + for (let index = 0; index < panelConstraints.length; index++) { + const unsafeSize = nextLayout[index]; + if (unsafeSize == null) { + throw new Error(`No layout data found for index ${index}`); + } + + const safeSize = resizePanel({ + panelConstraints, + panelIndex: index, + size: unsafeSize, + }); + + if (unsafeSize != safeSize) { + remainingSize += unsafeSize - safeSize; + + nextLayout[index] = safeSize; + } + } + + // If there is additional, left over space, assign it to any panel(s) that permits it + // (It's not worth taking multiple additional passes to evenly distribute) + if (!fuzzyNumbersEqual(remainingSize, 0)) { + for (let index = 0; index < panelConstraints.length; index++) { + const prevSize = nextLayout[index]; + if (prevSize == null) { + throw new Error(`No layout data found for index ${index}`); + } + const unsafeSize = prevSize + remainingSize; + const safeSize = resizePanel({ + panelConstraints, + panelIndex: index, + size: unsafeSize, + }); + + if (prevSize !== safeSize) { + remainingSize -= safeSize - prevSize; + nextLayout[index] = safeSize; + + // Once we've used up the remainder, bail + if (fuzzyNumbersEqual(remainingSize, 0)) { + break; + } + } + } + } + + return nextLayout; +} diff --git a/packages/boxel-ui/addon/src/helpers/deterministic-color-from-string.ts b/packages/boxel-ui/addon/src/helpers/deterministic-color-from-string.ts index c1e6a02eb9..418399da97 100644 --- a/packages/boxel-ui/addon/src/helpers/deterministic-color-from-string.ts +++ b/packages/boxel-ui/addon/src/helpers/deterministic-color-from-string.ts @@ -7,7 +7,7 @@ import { } from './color-tools.ts'; // Selects a random color from a set of colors based on the input string -export function deterministicColorFromString(str: string): string { +export function deterministicColorFromString(str?: string | null): string { if (!str) { return '#EEEEEE'; } diff --git a/packages/boxel-ui/addon/src/styles/variables.css b/packages/boxel-ui/addon/src/styles/variables.css index 6ba0289149..d64cb16315 100644 --- a/packages/boxel-ui/addon/src/styles/variables.css +++ b/packages/boxel-ui/addon/src/styles/variables.css @@ -157,6 +157,7 @@ /* Status colors */ --boxel-error-100: #dc0202; /* alert - attention - error */ --boxel-error-200: #ed0000; + --boxel-error-300: #ff0000; --boxel-warning-100: var(--boxel-yellow); /* warning - notification */ --boxel-success-100: var(--boxel-green); --boxel-success-200: var(--boxel-teal); diff --git a/packages/boxel-ui/addon/tsconfig.json b/packages/boxel-ui/addon/tsconfig.json index 110abf6fd9..fbb48fb840 100644 --- a/packages/boxel-ui/addon/tsconfig.json +++ b/packages/boxel-ui/addon/tsconfig.json @@ -29,7 +29,7 @@ "https://cardstack.com/base/*": ["../../base/*"], "*": ["./src/types/*"], "@cardstack/boxel-ui": ["./src"], - "@cardstack/boxel-ui/*": ["./src/*"] + "@cardstack/boxel-ui/*": ["./src/*"], }, "typeRoots": ["./src/types"] }, diff --git a/packages/boxel-ui/test-app/tests/integration/components/resizable-panel-group-test.gts b/packages/boxel-ui/test-app/tests/integration/components/resizable-panel-group-test.gts index feec0a96f6..ea48d1da88 100644 --- a/packages/boxel-ui/test-app/tests/integration/components/resizable-panel-group-test.gts +++ b/packages/boxel-ui/test-app/tests/integration/components/resizable-panel-group-test.gts @@ -3,49 +3,44 @@ import { setupRenderingTest } from 'ember-qunit'; import { doubleClick, render, RenderingTestContext } from '@ember/test-helpers'; import { htmlSafe } from '@ember/template'; import { ResizablePanelGroup } from '@cardstack/boxel-ui/components'; +import { not } from '@cardstack/boxel-ui/helpers'; import { tracked } from '@glimmer/tracking'; import { triggerEvent } from '@ember/test-helpers'; const RESIZE_HANDLE_WIDTH = 15.126; -const PANEL_INDEX_1_MIN_LENGTH = 50; +const PANEL_INDEX_1_MIN_SIZE = 15; class PanelProperties { - @tracked lengthPx?: number; + @tracked defaultSize?: number; + @tracked minSize?: number; + @tracked maxSize?: number; + @tracked collapsible: boolean; @tracked isHidden?: boolean; - @tracked defaultLengthFraction?: number; - @tracked minLengthPx?: number; - collapsible: boolean; outerContainerStyle?: string; showResizeHandle?: boolean; constructor( panelArgs: { - lengthPx?: number; - isHidden?: boolean; - defaultLengthFraction?: number; - minLengthPx?: number; + defaultSize?: number; + minSize?: number; + maxSize?: number; + collapsible?: boolean; outerContainerStyle?: string; showResizeHandle?: boolean; - collapsible?: boolean; + isHidden?: boolean; } = {}, testArgs: { outerContainerStyle?: string; showResizeHandle?: boolean; } = {}, ) { - let { - lengthPx, - isHidden = false, - defaultLengthFraction, - minLengthPx, - collapsible, - } = panelArgs; + let { defaultSize, minSize, maxSize, collapsible, isHidden } = panelArgs; let { outerContainerStyle, showResizeHandle } = testArgs; - this.lengthPx = lengthPx; - this.isHidden = isHidden; - this.defaultLengthFraction = defaultLengthFraction; - this.minLengthPx = minLengthPx; + this.defaultSize = defaultSize; + this.minSize = minSize; + this.maxSize = maxSize; this.collapsible = collapsible ?? true; + this.isHidden = isHidden; this.showResizeHandle = showResizeHandle; @@ -77,6 +72,94 @@ let orientationPropertiesToTest = [ }, ]; +let moveResizePanelHandle = async function ({ + panelIndex, + orientation, + moveDelta, // A negative indicates movement to the left in a horizontal orientation and upward in a vertical orientation." + hitAreaMargin = 0, +}: { + panelIndex: number; + orientation: string; + moveDelta: number; + hitAreaMargin?: number; +}) { + let groupEl = document.querySelector('[data-boxel-panel-group]'); + if (!groupEl) { + throw new Error(`panelGroup is not found`); + } + let resizePanelHandles = document.querySelectorAll( + `[data-boxel-panel-resize-handle-id]`, + ); + let resizeHandleId = resizePanelHandles[panelIndex].getAttribute( + 'data-boxel-panel-resize-handle-id', + ); + if (!resizeHandleId) { + throw new Error(`resizePanelHandle with index: ${panelIndex} is not found`); + } + + let groupRect = groupEl.getBoundingClientRect(); + let groupSizeInPixels = + orientation === 'horizontal' ? groupRect.width : groupRect.height; + let resizeHandleRect = + resizePanelHandles[panelIndex].children[0]!.getBoundingClientRect(); + let moveDeltaInPixels = (groupSizeInPixels * moveDelta) / 100; + await triggerEvent( + `[data-boxel-panel-resize-handle-id="${resizeHandleId}"]`, + 'pointerdown', + orientation === 'horizontal' + ? { + clientX: resizeHandleRect.x + hitAreaMargin, + clientY: resizeHandleRect.y, + } + : { + clientX: resizeHandleRect.x, + clientY: resizeHandleRect.y + hitAreaMargin, + }, + ); + await triggerEvent( + `[data-boxel-panel-resize-handle-id="${resizeHandleId}"]`, + 'pointermove', + orientation === 'horizontal' + ? { + clientX: resizeHandleRect.x + moveDeltaInPixels + hitAreaMargin, + clientY: resizeHandleRect.y, + } + : { + clientX: resizeHandleRect.x, + clientY: resizeHandleRect.y + moveDeltaInPixels + hitAreaMargin, + }, + ); + await triggerEvent( + `[data-boxel-panel-resize-handle-id="${resizeHandleId}"]`, + 'pointerup', + ); + await waitForRerender(); +}; + +let assertPanels = function ({ + assert, + orientation, + panelSizesInPixels, +}: { + assert: Assert; + orientation: string; + panelSizesInPixels: string[]; +}) { + let elements = document.querySelectorAll('[data-test-panel-index]'); + let computedStyles = Array.from(elements).map((element) => + window.getComputedStyle(element), + ); + assert.deepEqual(computedStyles.length, panelSizesInPixels.length); + for (let index = 0; index < panelSizesInPixels.length; index++) { + assert.deepEqual( + orientation === 'horizontal' + ? computedStyles[index].width + : computedStyles[index].height, + panelSizesInPixels[index], + ); + } +}; + orientationPropertiesToTest.forEach((orientationProperties) => { module( `Integration | ResizablePanelGroup | ${orientationProperties.orientation}`, @@ -86,11 +169,10 @@ orientationPropertiesToTest.forEach((orientationProperties) => { this.renderController = new RenderController(); this.renderController.panels = [ new PanelProperties( - { defaultLengthFraction: 0.6 }, + { defaultSize: 60 }, { showResizeHandle: true, outerContainerStyle: ` - border: 1px solid red; ${orientationProperties.dimension}: 100%; overflow-${orientationProperties.axis}: auto `, @@ -98,12 +180,11 @@ orientationPropertiesToTest.forEach((orientationProperties) => { ), new PanelProperties( { - defaultLengthFraction: 0.4, - minLengthPx: PANEL_INDEX_1_MIN_LENGTH, + defaultSize: 40, + minSize: PANEL_INDEX_1_MIN_SIZE, }, { outerContainerStyle: ` - border: 1px solid red; ${orientationProperties.dimension}: 100% `, }, @@ -132,30 +213,31 @@ orientationPropertiesToTest.forEach((orientationProperties) => { as |ResizablePanel ResizeHandle| > {{#each renderController.panels as |panel index|}} - -
-
- Panel - {{index}} +
+
+ Panel + {{index}} +
-
- - {{#if panel.showResizeHandle}} - + + {{#if panel.showResizeHandle}} + + {{/if}} {{/if}} {{/each}} @@ -164,474 +246,321 @@ orientationPropertiesToTest.forEach((orientationProperties) => { await waitForRerender(); } - test(`it can lay out panels with ${orientationProperties.orientation} orientation (default)`, async function (assert) { - this.renderController.containerStyle = ` - ${orientationProperties.dimension}: ${300 + RESIZE_HANDLE_WIDTH}px; - `; - - await renderResizablePanelGroup(this.renderController); - - assert.hasNumericStyle( - '[data-test-panel-index="0"]', - orientationProperties.dimension, - 300 * 0.6, - 1, - ); - assert.hasNumericStyle( - '[data-test-panel-index="1"]', - orientationProperties.dimension, - 300 * 0.4, - 1, - ); - }); - - test(`it can lay out panels with ${orientationProperties.orientation} orientation (length specified)`, async function (assert) { - this.renderController.containerStyle = ` - ${orientationProperties.dimension}: ${500 + RESIZE_HANDLE_WIDTH}px; - `; - - this.renderController.panels[0].lengthPx = 355; - this.renderController.panels[1].lengthPx = 143; - - await renderResizablePanelGroup(this.renderController); - - assert.hasNumericStyle( - '[data-test-panel-index="0"]', - orientationProperties.dimension, - 355, - 1, - ); - assert.hasNumericStyle( - '[data-test-panel-index="1"]', - orientationProperties.dimension, - 143, - 1, - ); - }); - - test(`it respects ${orientationProperties.orientation} minLength (default)`, async function (assert) { - this.renderController.containerStyle = ` - ${orientationProperties.dimension}: 105px; - `; - - await renderResizablePanelGroup(this.renderController); - - assert.hasNumericStyle( - '[data-test-panel-index="0"]', - orientationProperties.dimension, - 40, - 1, - ); - assert.hasNumericStyle( - '[data-test-panel-index="1"]', - orientationProperties.dimension, - PANEL_INDEX_1_MIN_LENGTH, - 1, - ); - }); - - test(`it respects ${orientationProperties.orientation} minLength (length specified)`, async function (assert) { - this.renderController.containerStyle = ` - ${orientationProperties.dimension}: 105px; - `; - - this.renderController.panels[0].lengthPx = 45; - this.renderController.panels[1].lengthPx = 45; - - await renderResizablePanelGroup(this.renderController); - - assert.hasNumericStyle( - '[data-test-panel-index="0"]', - orientationProperties.dimension, - 40, - 2, - ); - assert.hasNumericStyle( - '[data-test-panel-index="1"]', - orientationProperties.dimension, - PANEL_INDEX_1_MIN_LENGTH, - 2, - ); - }); - - test(`it adjusts to its container growing (default)`, async function (assert) { + test(`it can lay out panels with a defined defaultSize and ${orientationProperties.orientation} orientation`, async function (assert) { + let containerSize = 300 + RESIZE_HANDLE_WIDTH; this.renderController.containerStyle = ` - ${orientationProperties.dimension}: ${200 + RESIZE_HANDLE_WIDTH}px; + ${orientationProperties.dimension}: ${containerSize}px; `; await renderResizablePanelGroup(this.renderController); - this.renderController.containerStyle = ` - ${orientationProperties.dimension}: ${400 + RESIZE_HANDLE_WIDTH}px; - `; + assertPanels({ + assert, + orientation: orientationProperties.orientation, + panelSizesInPixels: ['180px', '120px'], + }); + + await moveResizePanelHandle({ + panelIndex: 0, + orientation: orientationProperties.orientation, + moveDelta: -10, + }); + assertPanels({ + assert, + orientation: orientationProperties.orientation, + panelSizesInPixels: ['150px', '150px'], + }); + + await moveResizePanelHandle({ + panelIndex: 0, + orientation: orientationProperties.orientation, + moveDelta: 20, + }); + assertPanels({ + assert, + orientation: orientationProperties.orientation, + panelSizesInPixels: ['210px', '90px'], + }); + + await doubleClick('[data-test-resize-handle]'); await waitForRerender(); + assertPanels({ + assert, + orientation: orientationProperties.orientation, + panelSizesInPixels: ['300px', '0px'], + }); - assert.hasNumericStyle( - '[data-test-panel-index="0"]', - orientationProperties.dimension, - 240, - 1, - ); - assert.hasNumericStyle( - '[data-test-panel-index="1"]', - orientationProperties.dimension, - 160, - 1, - ); - }); - - test(`it adjusts to its container growing (length specified)`, async function (assert) { - this.renderController.containerStyle = ` - ${orientationProperties.dimension}: ${200 + RESIZE_HANDLE_WIDTH}px; - `; - - this.renderController.panels[0].lengthPx = 100; - this.renderController.panels[1].lengthPx = 100; - - await renderResizablePanelGroup(this.renderController); - - assert.hasNumericStyle( - '[data-test-panel-index="0"]', - orientationProperties.dimension, - 100, - 1.5, - ); - assert.hasNumericStyle( - '[data-test-panel-index="1"]', - orientationProperties.dimension, - 100, - 1.5, - ); - - this.renderController.containerStyle = ` - ${orientationProperties.dimension}: ${400 + RESIZE_HANDLE_WIDTH}px; - `; + await doubleClick('[data-test-resize-handle]'); await waitForRerender(); - - assert.hasNumericStyle( - '[data-test-panel-index="0"]', - orientationProperties.dimension, - 200, - 1.5, - ); - assert.hasNumericStyle( - '[data-test-panel-index="1"]', - orientationProperties.dimension, - 200, - 1.5, - ); - }); - - test(`it adjusts to its container shrinking (default)`, async function (assert) { - this.renderController.containerStyle = ` - ${orientationProperties.dimension}: ${400 + RESIZE_HANDLE_WIDTH}x; - `; - - await renderResizablePanelGroup(this.renderController); + assertPanels({ + assert, + orientation: orientationProperties.orientation, + panelSizesInPixels: ['210px', '90px'], + }); + + // Update container size to simulate resizing window case + let newContainerSize = 600 + RESIZE_HANDLE_WIDTH; this.renderController.containerStyle = ` - ${orientationProperties.dimension}: ${200 + RESIZE_HANDLE_WIDTH}px; + ${orientationProperties.dimension}: ${newContainerSize}px; `; - await waitForRerender(); - - assert.hasNumericStyle( - '[data-test-panel-index="0"]', - orientationProperties.dimension, - 120, - 1, - ); - assert.hasNumericStyle( - '[data-test-panel-index="1"]', - orientationProperties.dimension, - 80, - 1, - ); + assertPanels({ + assert, + orientation: orientationProperties.orientation, + panelSizesInPixels: ['420px', '180px'], + }); }); - test(`it maintans ratio when its container shrinks`, async function (assert) { + test(`it can lay out panels with a defined minSize and ${orientationProperties.orientation} orientation`, async function (assert) { + this.renderController.panels[0].minSize = 40; + let containerSize = 300 + RESIZE_HANDLE_WIDTH; this.renderController.containerStyle = ` - ${orientationProperties.dimension}: 417px; + ${orientationProperties.dimension}: ${containerSize}px; `; - // length ratio panel 1 and panel 2 is 3:2 - this.renderController.panels[0].lengthPx = 240; - this.renderController.panels[1].lengthPx = 160; - await renderResizablePanelGroup(this.renderController); - assert.hasNumericStyle( - '[data-test-panel-index="0"]', - orientationProperties.dimension, - 240, - 1, - ); - assert.hasNumericStyle( - '[data-test-panel-index="1"]', - orientationProperties.dimension, - 160, - 1, - ); - - this.renderController.containerStyle = ` - ${orientationProperties.dimension}: 217px; - `; + assertPanels({ + assert, + orientation: orientationProperties.orientation, + panelSizesInPixels: ['180px', '120px'], + }); + + await moveResizePanelHandle({ + panelIndex: 0, + orientation: orientationProperties.orientation, + moveDelta: -10, + }); + assertPanels({ + assert, + orientation: orientationProperties.orientation, + panelSizesInPixels: ['150px', '150px'], + }); + + await moveResizePanelHandle({ + panelIndex: 0, + orientation: orientationProperties.orientation, + moveDelta: -20, + }); + assertPanels({ + assert, + orientation: orientationProperties.orientation, + panelSizesInPixels: ['0px', '300px'], + }); + + await moveResizePanelHandle({ + panelIndex: 0, + orientation: orientationProperties.orientation, + moveDelta: 10, + }); + assertPanels({ + assert, + orientation: orientationProperties.orientation, + panelSizesInPixels: ['120px', '180px'], + }); + + await moveResizePanelHandle({ + panelIndex: 0, + orientation: orientationProperties.orientation, + moveDelta: 10, + }); + assertPanels({ + assert, + orientation: orientationProperties.orientation, + panelSizesInPixels: ['150px', '150px'], + }); + + await doubleClick('[data-test-resize-handle]'); + await waitForRerender(); + assertPanels({ + assert, + orientation: orientationProperties.orientation, + panelSizesInPixels: ['300px', '0px'], + }); + await doubleClick('[data-test-resize-handle]'); await waitForRerender(); - // Maintain the ratio 3:2 when resizing - assert.hasNumericStyle( - '[data-test-panel-index="0"]', - orientationProperties.dimension, - 120, - 1, - ); - assert.hasNumericStyle( - '[data-test-panel-index="1"]', - orientationProperties.dimension, - 80, - 1, - ); + assertPanels({ + assert, + orientation: orientationProperties.orientation, + panelSizesInPixels: ['150px', '150px'], + }); }); - test(`it adjusts to its container shrinking and growing`, async function (assert) { + test(`it can lay out panels with a defined minSize, not collapsible, and ${orientationProperties.orientation} orientation`, async function (assert) { + this.renderController.panels[0].minSize = 40; + this.renderController.panels[0].collapsible = false; + let containerSize = 300 + RESIZE_HANDLE_WIDTH; this.renderController.containerStyle = ` - ${orientationProperties.dimension}: ${600 + RESIZE_HANDLE_WIDTH}px; + ${orientationProperties.dimension}: ${containerSize}px; `; - this.renderController.panels[0].lengthPx = 400; - this.renderController.panels[1].lengthPx = 200; - await renderResizablePanelGroup(this.renderController); - this.renderController.panels[0].lengthPx = 50; - this.renderController.panels[1].lengthPx = 550; - - await waitForRerender(); - - await doubleClick('[data-test-resize-handle]'); // Double-click to hide recent + assertPanels({ + assert, + orientation: orientationProperties.orientation, + panelSizesInPixels: ['180px', '120px'], + }); + + await moveResizePanelHandle({ + panelIndex: 0, + orientation: orientationProperties.orientation, + moveDelta: -10, + }); + assertPanels({ + assert, + orientation: orientationProperties.orientation, + panelSizesInPixels: ['150px', '150px'], + }); + + await moveResizePanelHandle({ + panelIndex: 0, + orientation: orientationProperties.orientation, + moveDelta: -20, + }); + assertPanels({ + assert, + orientation: orientationProperties.orientation, + panelSizesInPixels: ['120px', '180px'], + }); + + await doubleClick('[data-test-resize-handle]'); await waitForRerender(); + assertPanels({ + assert, + orientation: orientationProperties.orientation, + panelSizesInPixels: ['300px', '0px'], + }); - assert.hasNumericStyle( - '[data-test-panel-index="0"]', - orientationProperties.dimension, - 600, - 1, - ); - assert.hasNumericStyle( - '[data-test-panel-index="1"]', - orientationProperties.dimension, - 0, - 2, - ); - - // shrink container by ~5 - this.renderController.containerStyle = ` - ${orientationProperties.dimension}: ${300 + RESIZE_HANDLE_WIDTH}px; - `; - - await waitForRerender(); - - assert.hasNumericStyle( - '[data-test-panel-index="0"]', - orientationProperties.dimension, - 300, - 1, - ); - assert.hasNumericStyle( - '[data-test-panel-index="1"]', - orientationProperties.dimension, - 0, - 2, - ); - - await doubleClick('[data-test-resize-handle]'); // Double-click to unhide recent - + await doubleClick('[data-test-resize-handle]'); await waitForRerender(); + assertPanels({ + assert, + orientation: orientationProperties.orientation, + panelSizesInPixels: ['120px', '180px'], + }); + }); - assert.hasNumericStyle( - '[data-test-panel-index="0"]', - orientationProperties.dimension, - 180, - 1, - ); - assert.hasNumericStyle( - '[data-test-panel-index="1"]', - orientationProperties.dimension, - 120, - 1, - ); - - // increase window/container length to original length + test(`it can lay out panels with a defined maxSize and ${orientationProperties.orientation} orientation`, async function (assert) { + this.renderController.panels[0].maxSize = 80; + let containerSize = 300 + RESIZE_HANDLE_WIDTH; this.renderController.containerStyle = ` - ${orientationProperties.dimension}: 617px; + ${orientationProperties.dimension}: ${containerSize}px; `; - await waitForRerender(); + await renderResizablePanelGroup(this.renderController); - // expected behavior: panel length percentages would remain consistent - assert.hasNumericStyle( - '[data-test-panel-index="0"]', - orientationProperties.dimension, - 360, - 1, - ); - assert.hasNumericStyle( - '[data-test-panel-index="1"]', - orientationProperties.dimension, - 240, - 1, - ); + assertPanels({ + assert, + orientation: orientationProperties.orientation, + panelSizesInPixels: ['180px', '120px'], + }); + + await moveResizePanelHandle({ + panelIndex: 0, + orientation: orientationProperties.orientation, + moveDelta: 10, + }); + assertPanels({ + assert, + orientation: orientationProperties.orientation, + panelSizesInPixels: ['210px', '90px'], + }); + + await moveResizePanelHandle({ + panelIndex: 0, + orientation: orientationProperties.orientation, + moveDelta: 10, + }); + assertPanels({ + assert, + orientation: orientationProperties.orientation, + panelSizesInPixels: ['240px', '60px'], + }); + + await moveResizePanelHandle({ + panelIndex: 0, + orientation: orientationProperties.orientation, + moveDelta: 10, + }); + assertPanels({ + assert, + orientation: orientationProperties.orientation, + panelSizesInPixels: ['240px', '60px'], + }); }); - test(`it excludes hidden panels from participating in layout`, async function (assert) { + test(`it can recalculate the panels with ${orientationProperties.orientation} orientation if a panel is hidden`, async function (assert) { + let containerSize = 300 + RESIZE_HANDLE_WIDTH; this.renderController.containerStyle = ` - ${orientationProperties.dimension}: ${200 + RESIZE_HANDLE_WIDTH}px; + ${orientationProperties.dimension}: ${containerSize}px; `; - this.renderController.panels = [ - new PanelProperties( - { defaultLengthFraction: 0.6 }, - { - outerContainerStyle: ` - ${orientationProperties.dimension}: 100%; - overflow-${orientationProperties.axis}: auto - `, - }, - ), - new PanelProperties( - { - defaultLengthFraction: 0.4, - minLengthPx: PANEL_INDEX_1_MIN_LENGTH, - isHidden: true, - }, - { - outerContainerStyle: ` - ${orientationProperties.dimension}: 100% - `, - }, - ), - ]; - await renderResizablePanelGroup(this.renderController); - await waitForRerender(); - - assert.hasNumericStyle( - '[data-test-panel-index="0"]', - orientationProperties.dimension, - 215, - 1, - ); - assert.hasNumericStyle( - '[data-test-panel-index="1"]', - orientationProperties.dimension, - 0, - 0, - ); - - this.renderController.panels[1].isHidden = false; - await waitForRerender(); - - assert.hasNumericStyle( - '[data-test-panel-index="0"]', - orientationProperties.dimension, - 165, - 1, - ); - assert.hasNumericStyle( - '[data-test-panel-index="1"]', - orientationProperties.dimension, - PANEL_INDEX_1_MIN_LENGTH, - 1, - ); + assertPanels({ + assert, + orientation: orientationProperties.orientation, + panelSizesInPixels: ['180px', '120px'], + }); + + await moveResizePanelHandle({ + panelIndex: 0, + orientation: orientationProperties.orientation, + moveDelta: 10, + }); + assertPanels({ + assert, + orientation: orientationProperties.orientation, + panelSizesInPixels: ['210px', '90px'], + }); this.renderController.panels[1].isHidden = true; await waitForRerender(); - - assert.hasNumericStyle( - '[data-test-panel-index="0"]', - orientationProperties.dimension, - 215, - 1, - ); - assert.hasNumericStyle( - '[data-test-panel-index="1"]', - orientationProperties.dimension, - 0, - 0, - ); + assertPanels({ + assert, + orientation: orientationProperties.orientation, + panelSizesInPixels: ['300px'], + }); }); - test(`it stops expanding/shrinking if cursor is not in the right position`, async function (assert) { + test(`it stops expanding/shrinking panels with ${orientationProperties.orientation} orientation if cursor is not in the right position`, async function (assert) { + let containerSize = 300 + RESIZE_HANDLE_WIDTH; this.renderController.containerStyle = ` - ${orientationProperties.dimension}: ${300 + RESIZE_HANDLE_WIDTH}px; + ${orientationProperties.dimension}: ${containerSize}px; `; - this.renderController.panels[1].minLengthPx = 60; - this.renderController.panels[1].collapsible = false; await renderResizablePanelGroup(this.renderController); - assert.hasNumericStyle( - '[data-test-panel-index="0"]', - orientationProperties.dimension, - 300 * 0.6, - 1, - ); - assert.hasNumericStyle( - '[data-test-panel-index="1"]', - orientationProperties.dimension, - 300 * 0.4, - 1, - ); - - let resizeHandleRect = document - .querySelector('[data-test-resize-handle]')! - .getBoundingClientRect(); - await triggerEvent('[data-test-resize-handle]', 'mousedown'); - await triggerEvent( - '[data-test-resize-handle]', - 'mousemove', - orientationProperties.orientation === 'horizontal' - ? { - clientX: resizeHandleRect.x + 60, - clientY: 25, - } - : { - clientX: 25, - clientY: resizeHandleRect.y + 60, - }, - ); - assert.hasNumericStyle( - '[data-test-panel-index="1"]', - orientationProperties.dimension, - this.renderController.panels[1].minLengthPx, - 1, - ); - - // Mouse move event doesn't give any effects - // since the cursor drags past minimum length - resizeHandleRect = document - .querySelector('[data-test-resize-handle]')! - .getBoundingClientRect(); - await triggerEvent( - '[data-test-resize-handle]', - 'mousemove', - orientationProperties.orientation === 'horizontal' - ? { - clientX: resizeHandleRect.x + 40, - clientY: 25, - } - : { - clientX: 25, - clientY: resizeHandleRect.y + 40, - }, - ); - assert.hasNumericStyle( - '[data-test-panel-index="1"]', - orientationProperties.dimension, - this.renderController.panels[1].minLengthPx, - 1, - ); - await triggerEvent('[data-test-resize-handle]', 'mouseup'); + assertPanels({ + assert, + orientation: orientationProperties.orientation, + panelSizesInPixels: ['180px', '120px'], + }); + + await moveResizePanelHandle({ + panelIndex: 0, + orientation: orientationProperties.orientation, + moveDelta: 10, + }); + assertPanels({ + assert, + orientation: orientationProperties.orientation, + panelSizesInPixels: ['210px', '90px'], + }); + + await moveResizePanelHandle({ + panelIndex: 0, + orientation: orientationProperties.orientation, + moveDelta: 10, + hitAreaMargin: RESIZE_HANDLE_WIDTH + 25, // Put cursor outside the right hit area + }); + assertPanels({ + assert, + orientation: orientationProperties.orientation, + panelSizesInPixels: ['210px', '90px'], + }); }); }, ); diff --git a/packages/experiments-realm/Account/77976251-6bc8-4a8c-972f-f9d588e8434d.json b/packages/experiments-realm/Account/77976251-6bc8-4a8c-972f-f9d588e8434d.json index 03b3d6a4eb..770af1d9f3 100644 --- a/packages/experiments-realm/Account/77976251-6bc8-4a8c-972f-f9d588e8434d.json +++ b/packages/experiments-realm/Account/77976251-6bc8-4a8c-972f-f9d588e8434d.json @@ -2,39 +2,47 @@ "data": { "type": "card", "attributes": { - "title": null, + "shippingAddress": { + "addressLine1": null, + "addressLine2": null, + "city": null, + "state": null, + "postalCode": null, + "country": { + "name": null, + "code": null + }, + "poBoxNumber": null + }, + "billingAddress": { + "addressLine1": null, + "addressLine2": null, + "city": null, + "state": null, + "postalCode": null, + "country": { + "name": null, + "code": null + }, + "poBoxNumber": null + }, "description": null, "thumbnailURL": null }, "relationships": { "company": { "links": { - "self": "../Company/05326db9-ea5c-4f20-b516-ac3f2282e973" + "self": null } }, "primaryContact": { "links": { - "self": "../Contact/ed6f4f12-01ae-4c5c-a507-6d128eb09a17" - } - }, - "contacts.0": { - "links": { - "self": "../Contact/ed6f4f12-01ae-4c5c-a507-6d128eb09a17" - } - }, - "contacts.1": { - "links": { - "self": "../Customer/67e87a6c-00d0-4c48-9318-f0c96daa4cae" - } - }, - "deals.0": { - "links": { - "self": "../Deal/4fdd3053-14f1-4800-827f-d3e2a5c44ae8" + "self": null } }, - "deals.1": { + "contacts": { "links": { - "self": "../Deal/f0d769bd-e347-4203-9a96-89332cfba0e2" + "self": null } } }, diff --git a/packages/experiments-realm/Account/bf437dc3-1f96-45e5-a057-3487c6a5c2f7.json b/packages/experiments-realm/Account/bf437dc3-1f96-45e5-a057-3487c6a5c2f7.json index a4d0ed525b..de3832396b 100644 --- a/packages/experiments-realm/Account/bf437dc3-1f96-45e5-a057-3487c6a5c2f7.json +++ b/packages/experiments-realm/Account/bf437dc3-1f96-45e5-a057-3487c6a5c2f7.json @@ -2,14 +2,37 @@ "data": { "type": "card", "attributes": { - "title": null, + "shippingAddress": { + "addressLine1": null, + "addressLine2": null, + "city": null, + "state": null, + "postalCode": null, + "country": { + "name": null, + "code": null + }, + "poBoxNumber": null + }, + "billingAddress": { + "addressLine1": null, + "addressLine2": null, + "city": null, + "state": null, + "postalCode": null, + "country": { + "name": null, + "code": null + }, + "poBoxNumber": null + }, "description": null, "thumbnailURL": null }, "relationships": { "company": { "links": { - "self": null + "self": "../Company/9203c173-3420-44b3-863d-90defd7c57c8" } }, "primaryContact": { @@ -21,11 +44,6 @@ "links": { "self": null } - }, - "deals": { - "links": { - "self": null - } } }, "meta": { @@ -35,4 +53,4 @@ } } } -} \ No newline at end of file +} diff --git a/packages/catalog-realm/AiAppGenerator/ai-app-gen-thumbnail.png b/packages/experiments-realm/AiAppGenerator/ai-app-gen-thumbnail.png similarity index 100% rename from packages/catalog-realm/AiAppGenerator/ai-app-gen-thumbnail.png rename to packages/experiments-realm/AiAppGenerator/ai-app-gen-thumbnail.png diff --git a/packages/seed-realm/AiAppGenerator/ai-app-generator.json b/packages/experiments-realm/AiAppGenerator/ai-app-generator.json similarity index 99% rename from packages/seed-realm/AiAppGenerator/ai-app-generator.json rename to packages/experiments-realm/AiAppGenerator/ai-app-generator.json index 260fa7fceb..6578d17702 100644 --- a/packages/seed-realm/AiAppGenerator/ai-app-generator.json +++ b/packages/experiments-realm/AiAppGenerator/ai-app-generator.json @@ -36,7 +36,7 @@ }, "meta": { "adoptsFrom": { - "module": "/catalog/ai-app-generator", + "module": "../ai-app-generator", "name": "AiAppGenerator" } } diff --git a/packages/catalog-realm/AiAppGenerator/create-boxel-app-command.ts b/packages/experiments-realm/AiAppGenerator/create-boxel-app-command.ts similarity index 74% rename from packages/catalog-realm/AiAppGenerator/create-boxel-app-command.ts rename to packages/experiments-realm/AiAppGenerator/create-boxel-app-command.ts index 58da9d62b2..58f088301e 100644 --- a/packages/catalog-realm/AiAppGenerator/create-boxel-app-command.ts +++ b/packages/experiments-realm/AiAppGenerator/create-boxel-app-command.ts @@ -6,7 +6,6 @@ import CreateProductRequirementsInstance, { import ShowCardCommand from '@cardstack/boxel-host/commands/show-card'; import WriteTextFileCommand from '@cardstack/boxel-host/commands/write-text-file'; import GenerateCodeCommand from './generate-code-command'; -import { GenerateCodeInput } from './generate-code-command'; import { AppCard } from '../app-card'; import SaveCardCommand from '@cardstack/boxel-host/commands/save-card'; @@ -27,36 +26,26 @@ export default class CreateBoxelApp extends Command< await createPRDCommand.execute(input); let showCardCommand = new ShowCardCommand(this.commandContext); - let ShowCardInput = await showCardCommand.getInputType(); - - let showPRDCardInput = new ShowCardInput(); - showPRDCardInput.cardToShow = prdCard; - await showCardCommand.execute(showPRDCardInput); + await showCardCommand.execute({ cardToShow: prdCard }); let generateCodeCommand = new GenerateCodeCommand(this.commandContext); - let generateCodeInput = new GenerateCodeInput({ + let { code, appName } = await generateCodeCommand.execute({ roomId, productRequirements: prdCard, }); - let { code, appName } = await generateCodeCommand.execute( - generateCodeInput, - ); - // Generate a unique name for the module using timestamp let timestamp = Date.now(); let moduleName = `generated-apps/${timestamp}/${appName}`; let filePath = `${moduleName}.gts`; let moduleId = new URL(moduleName, input.realm).href; let writeFileCommand = new WriteTextFileCommand(this.commandContext); - let writeFileInput = new (await writeFileCommand.getInputType())({ + await writeFileCommand.execute({ path: filePath, content: code, realm: input.realm, }); - await writeFileCommand.execute(writeFileInput); - // get the app card def from the module let loader = (import.meta as any).loader; let module = await loader.import(moduleId + '.gts'); @@ -77,18 +66,13 @@ export default class CreateBoxelApp extends Command< // save card let saveCardCommand = new SaveCardCommand(this.commandContext); - let SaveCardInputType = await saveCardCommand.getInputType(); - - let saveCardInput = new SaveCardInputType({ + await saveCardCommand.execute({ realm: input.realm, card: myAppCard, }); - await saveCardCommand.execute(saveCardInput); // show the app card - let showAppCardInput = new ShowCardInput(); - showAppCardInput.cardToShow = myAppCard; - await showCardCommand.execute(showAppCardInput); + await showCardCommand.execute({ cardToShow: myAppCard }); return myAppCard; } diff --git a/packages/catalog-realm/AiAppGenerator/create-product-requirements-command.ts b/packages/experiments-realm/AiAppGenerator/create-product-requirements-command.ts similarity index 82% rename from packages/catalog-realm/AiAppGenerator/create-product-requirements-command.ts rename to packages/experiments-realm/AiAppGenerator/create-product-requirements-command.ts index d87ce8340b..50c1062450 100644 --- a/packages/catalog-realm/AiAppGenerator/create-product-requirements-command.ts +++ b/packages/experiments-realm/AiAppGenerator/create-product-requirements-command.ts @@ -11,6 +11,8 @@ import { SkillCard } from 'https://cardstack.com/base/skill-card'; import SaveCardCommand from '@cardstack/boxel-host/commands/save-card'; import PatchCardCommand from '@cardstack/boxel-host/commands/patch-card'; import ReloadCardCommand from '@cardstack/boxel-host/commands/reload-card'; +import CreateAIAssistantRoomCommand from '@cardstack/boxel-host/commands/create-ai-assistant-room'; +import AddSkillsToRoomCommand from '@cardstack/boxel-host/commands/add-skills-to-room'; export class CreateProductRequirementsInput extends CardDef { @field targetAudience = contains(StringField); @@ -40,10 +42,10 @@ export default class CreateProductRequirementsInstance extends Command< Update the appTitle. Update the prompt to be grammatically accurate. Description should be 1 or 2 short sentences. - In overview, provide 1 or 2 paragraph summary of the most important ways this app will meet the needs of the target audience. The capabilites of the platform allow creating types that can be linked to other types, and creating fields. - + In overview, provide 1 or 2 paragraph summary of the most important ways this app will meet the needs of the target audience. The capabilites of the platform allow creating types that can be linked to other types, and creating fields. + For the schema, consider the types required. Write out the schema as a mermaid class diagram. - + NEVER offer to update the card, you MUST call patchCard in your response.`, }); } @@ -59,23 +61,33 @@ export default class CreateProductRequirementsInstance extends Command< let prdCard = new ProductRequirementDocument(); let saveCardCommand = new SaveCardCommand(this.commandContext); - let SaveCardInputType = await saveCardCommand.getInputType(); - await saveCardCommand.execute( - new SaveCardInputType({ - realm: input.realm, - card: prdCard, - }), - ); + await saveCardCommand.execute({ + realm: input.realm, + card: prdCard, + }); // Get patch command, this takes the card and returns a command that can be used to patch the card let patchPRDCommand = new PatchCardCommand(this.commandContext, { cardType: ProductRequirementDocument, }); - let { roomId } = await this.commandContext.sendAiAssistantMessage({ + let createRoomCommand = new CreateAIAssistantRoomCommand( + this.commandContext, + ); + let { roomId } = await createRoomCommand.execute({ + name: 'Product Requirements Doc Creation', + }); + let addSkillsToRoomCommand = new AddSkillsToRoomCommand( + this.commandContext, + ); + await addSkillsToRoomCommand.execute({ + roomId, + skills: [this.skillCard], + }); + await this.commandContext.sendAiAssistantMessage({ + roomId, show: false, // maybe? open the side panel prompt: this.createPrompt(input), attachedCards: [prdCard], - skillCards: [this.skillCard], commands: [{ command: patchPRDCommand, autoExecute: true }], // this should persist over multiple messages, matrix service is responsible to tracking whic }); diff --git a/packages/catalog-realm/AiAppGenerator/generate-code-command.ts b/packages/experiments-realm/AiAppGenerator/generate-code-command.ts similarity index 95% rename from packages/catalog-realm/AiAppGenerator/generate-code-command.ts rename to packages/experiments-realm/AiAppGenerator/generate-code-command.ts index 279935d88a..9f95aa6526 100644 --- a/packages/catalog-realm/AiAppGenerator/generate-code-command.ts +++ b/packages/experiments-realm/AiAppGenerator/generate-code-command.ts @@ -2,13 +2,13 @@ import { CardDef, field, linksTo, - containsMany, contains, } from 'https://cardstack.com/base/card-api'; import { Command } from '@cardstack/runtime-common'; import { SkillCard } from 'https://cardstack.com/base/skill-card'; import StringField from 'https://cardstack.com/base/string'; import { ProductRequirementDocument } from '../product-requirement-document'; +import AddSkillsToRoomCommand from '@cardstack/boxel-host/commands/add-skills-to-room'; export class GenerateCodeInput extends CardDef { @field productRequirements = linksTo(() => ProductRequirementDocument); @@ -73,7 +73,7 @@ function constructModule(input: ConstructApplicationCodeInput) { import TextAreaField from 'https://cardstack.com/base/text-area'; import CodeRefField from 'https://cardstack.com/base/code-ref'; import { Base64ImageField } from 'https://cardstack.com/base/base64-image'; -import { AppCard } from '/catalog/app-card'; +import { AppCard } from '/experiments/app-card'; import { CardDef, field, @@ -91,6 +91,7 @@ import { and, bool, cn } from '@cardstack/boxel-ui/helpers'; import { baseRealm, getCard } from '@cardstack/runtime-common'; import { hash } from '@ember/helper'; import { on } from '@ember/modifier'; +import AddSkillsToRoomCommand from '@cardstack/boxel-host/commands/add-skills-to-room'; import { action } from '@ember/object'; import type Owner from '@ember/owner'; import GlimmerComponent from '@glimmer/component'; @@ -254,14 +255,19 @@ import { on } from '@ember/modifier'; let constructApplicationCodeCommand = new ConstructApplicationCodeCommand( this.commandContext, ); - + let addSkillsToRoomCommand = new AddSkillsToRoomCommand( + this.commandContext, + ); + await addSkillsToRoomCommand.execute({ + roomId: input.roomId, + skills: [this.skillCard], + }); await this.commandContext.sendAiAssistantMessage({ roomId: input.roomId, show: false, // maybe? open the side panel prompt: 'Generate code for the application given the product requirements, you do not need to strictly follow the schema if it does not seem appropriate for the application.', attachedCards: [input.productRequirements], - skillCards: [this.skillCard], commands: [ { command: constructApplicationCodeCommand, autoExecute: true }, ], diff --git a/packages/experiments-realm/Author/ad28d989-68a8-4bad-a8dc-05f9f724489c.json b/packages/experiments-realm/Author/ad28d989-68a8-4bad-a8dc-05f9f724489c.json new file mode 100644 index 0000000000..b9717d4005 --- /dev/null +++ b/packages/experiments-realm/Author/ad28d989-68a8-4bad-a8dc-05f9f724489c.json @@ -0,0 +1,51 @@ +{ + "data": { + "type": "card", + "attributes": { + "firstName": "Alexandra Maximiliana", + "lastName": "Montgomery-Barrington IV", + "bio": "A distinguished editorial leader with over a decade of experience shaping content strategies that captivate and engage audiences across digital platforms, Alexandra Maximiliana Montgomery-Barrington IV is renowned for her visionary approach to storytelling and editorial excellence. She has led high-performing teams to produce impactful narratives while driving innovation in the publishing industry. Alexandra is a strategic thinker with a passion for fostering collaboration, ensuring that every project delivers both creative quality and measurable results. When not overseeing editorial initiatives, she enjoys hiking through scenic landscapes, capturing moments through her lens, and immersing herself in a good book.", + "fullBio": "Alexandra Maximiliana Montgomery-Barrington IV is a highly respected editorial leader with more than a decade of experience in digital media and content strategy. Known for her keen eye for detail and her ability to craft compelling narratives, she has consistently delivered high-quality content that resonates with diverse audiences. Throughout her career, Alexandra has demonstrated a deep understanding of the evolving digital landscape, balancing creative vision with data-driven insights to produce work that not only engages but also drives measurable results. Her expertise spans editorial direction, content curation, and cross-functional team leadership, enabling her to oversee complex projects from conception to execution.\n\nA strategic thinker and innovative problem solver, Alexandra has a proven track record of building and leading editorial teams that thrive in dynamic environments. She has worked with industry-leading brands, shaping content strategies that align with business goals while maintaining a strong focus on storytelling and audience connection. Her leadership style is rooted in collaboration and fostering a culture of creativity and excellence, ensuring that every project exceeds expectations. Alexandra is also passionate about mentoring and developing talent, helping her teams grow both professionally and personally.\n\nOutside of her professional life, Alexandra is an avid outdoors enthusiast, finding inspiration in nature through hiking and photography. Her love of storytelling extends beyond the written word—capturing moments through her lens allows her to explore new perspectives and deepen her connection to the world around her. When she’s not leading editorial initiatives, you’ll find her immersed in a good book, seeking out new ideas and inspiration to fuel her next project. Alexandra's unique blend of editorial expertise, leadership, and creative passion has made her a trailblazer in the publishing world, constantly pushing the boundaries of what’s possible in digital content.", + "quote": "\"Storytelling is the bridge that connects ideas with impact, and great content is the foundation that makes it last.\"", + "contactLinks": [ + { + "label": "X", + "value": "https://x.com/alexandra.montgomery-barrington-boxel" + }, + { + "label": "LinkedIn", + "value": "https://linkedin.com/alexandra.montgomery-barrington-boxel" + }, + { + "label": "Email", + "value": "alexandra.montgomery-barrington@companyname.com" + } + ], + "email": "alexandra.montgomery-barrington@companyname.com", + "featuredImage": { + "imageUrl": "https://boxel-images.boxel.ai/app-assets/portraits/photo-1521227889351-bf6f5b2e4e37.jpeg", + "credit": "Photo via Unsplash", + "caption": "Alexandra Maximiliana Montgomery-Barrington IV", + "altText": "Alexandra Maximiliana Montgomery-Barrington IV", + "size": "actual", + "height": null, + "width": null + }, + "description": "Editor-in-Chief and Senior Director of Content Strategy, Editorial Innovation, and Cross-Platform Publishing Initiatives", + "thumbnailURL": "https://boxel-images.boxel.ai/app-assets/portraits/photo-1521227889351-bf6f5b2e4e37.jpeg" + }, + "relationships": { + "blog": { + "links": { + "self": "../BlogApp/ramped" + } + } + }, + "meta": { + "adoptsFrom": { + "module": "../author", + "name": "Author" + } + } + } +} \ No newline at end of file diff --git a/packages/experiments-realm/Author/alice-enwunder.json b/packages/experiments-realm/Author/alice-enwunder.json index 5c54d3a8db..371dfc4458 100644 --- a/packages/experiments-realm/Author/alice-enwunder.json +++ b/packages/experiments-realm/Author/alice-enwunder.json @@ -4,14 +4,9 @@ "attributes": { "firstName": "Alice", "lastName": "Enwunder", - "photo": { - "altText": null, - "size": "actual", - "height": null, - "width": null, - "base64": null - }, - "body": "", + "bio": null, + "fullBio": null, + "quote": "Curiouser and curiouser", "contactLinks": [ { "label": "X", @@ -26,7 +21,25 @@ "value": "alice@email.com" } ], - "thumbnailURL": "https://images.unsplash.com/photo-1561569912-9f202ecac634?q=80&w=3135&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D" + "email": null, + "featuredImage": { + "imageUrl": "https://boxel-images.boxel.ai/app-assets/portraits/photo-1509868918748-a554ad25f858.jpeg", + "credit": null, + "caption": null, + "altText": "Alice Enwunder", + "size": "actual", + "height": null, + "width": null + }, + "description": null, + "thumbnailURL": "https://boxel-images.boxel.ai/app-assets/portraits/photo-1509868918748-a554ad25f858.jpeg" + }, + "relationships": { + "blog": { + "links": { + "self": null + } + } }, "meta": { "adoptsFrom": { diff --git a/packages/experiments-realm/Author/jane-doe.json b/packages/experiments-realm/Author/jane-doe.json index e1e680616e..01cfb9011f 100644 --- a/packages/experiments-realm/Author/jane-doe.json +++ b/packages/experiments-realm/Author/jane-doe.json @@ -4,15 +4,42 @@ "attributes": { "firstName": "Jane", "lastName": "Doe", - "photo": { - "altText": "", + "bio": "Jane Doe is the Senior Managing Editor at Ramped.com, where she leads content strategy, editorial direction, and ensures the highest standards of quality across all publications. With over a decade of experience in digital media and editorial management, Jane has a proven track record of shaping impactful narratives, growing engaged audiences, and collaborating with cross-functional teams to deliver compelling content. When she's not editing, you can find her exploring new books, hiking, or indulging in her love of photography.", + "fullBio": "Jane Doe is the Senior Managing Editor at *Ramped.com*, where she leads the editorial team in crafting high-impact content that resonates with a diverse, global audience. With over 10 years of experience in digital media, Jane has built a career around her passion for storytelling, strategic content development, and editorial excellence. At *Ramped.com*, she oversees the editorial direction for both long-form features and quick-hit content, ensuring that all materials meet rigorous standards for quality, accuracy, and engagement. Her role involves not only managing content workflows but also driving the site’s content strategy, optimizing for SEO, and integrating new multimedia formats to enhance user experience.\n\nBefore joining *Ramped*, Jane served in senior editorial positions at prominent media organizations, where she was instrumental in shaping editorial voice, increasing readership, and overseeing large teams of writers, editors, and designers. Her ability to adapt to evolving trends in digital media has helped these organizations expand their online presence and build strong, loyal audiences. She is particularly skilled in data-driven content strategies and has worked closely with product, marketing, and analytics teams to optimize content for growth and engagement.\n\nA firm believer in the power of mentorship, Jane is deeply committed to helping young writers develop their craft and grow professionally. She frequently leads editorial workshops and offers guidance on topics ranging from narrative structure to the nuances of digital publishing. Her approach to leadership emphasizes collaboration, creativity, and a keen attention to detail.\n\nWhen she's not editing or refining strategy, Jane enjoys exploring new books across various genres, hiking through nature trails, and experimenting with photography. She is also a dedicated advocate for promoting diversity in media and creating inclusive spaces for underrepresented voices within the industry.\n\nHer dedication to her craft and her team has made Jane an invaluable asset to *Ramped.com*, where she continues to shape the future of digital content and drive the organization’s mission forward.", + "quote": "“Great content isn’t just about words on a page—it's about creating experiences that resonate with people, spark conversation, and drive meaningful action.”", + "contactLinks": [ + { + "label": "X", + "value": "https://x.com/jane-doe-boxel" + }, + { + "label": "LinkedIn", + "value": "https://linkedin.com/jane-doe-boxel" + }, + { + "label": "Email", + "value": "jane.doe@email.com" + } + ], + "email": "jane.doe@email.com", + "featuredImage": { + "imageUrl": "https://boxel-images.boxel.ai/app-assets/portraits/photo-1481214110143-ed630356e1bb.jpeg", + "credit": "", + "caption": null, + "altText": "Jane Doe", "size": "actual", "height": null, - "width": null, - "base64": null + "width": null }, - "body": null, - "thumbnailURL": null + "description": "Senior Managing Editor at Ramped.com", + "thumbnailURL": "https://boxel-images.boxel.ai/app-assets/portraits/photo-1481214110143-ed630356e1bb.jpeg" + }, + "relationships": { + "blog": { + "links": { + "self": "../BlogApp/ramped" + } + } }, "meta": { "adoptsFrom": { diff --git a/packages/experiments-realm/BilingualBlog/85de4d7a-e9d8-459d-80a7-d9f0508c9ea1.json b/packages/experiments-realm/BilingualBlog/85de4d7a-e9d8-459d-80a7-d9f0508c9ea1.json index 4721d4fcba..bf334b6daa 100644 --- a/packages/experiments-realm/BilingualBlog/85de4d7a-e9d8-459d-80a7-d9f0508c9ea1.json +++ b/packages/experiments-realm/BilingualBlog/85de4d7a-e9d8-459d-80a7-d9f0508c9ea1.json @@ -3,9 +3,19 @@ "type": "card", "attributes": { "translation": "", - "title": null, + "headline": "", "slug": null, "body": "To quote wikipedia:\n\nMicroblogging is a form of blogging using short posts without titles known as microposts[1][2][3] (or status updates on a minority of websites like Meta Platforms'). Microblogs \"allow users to exchange small elements of content such as short sentences, individual images, or video links\",[1] which may be the major reason for their popularity.[4] Some popular social networks such as Twitter, Threads, Mastodon, Tumblr, Koo, and Instagram can be viewed as collections of microblogs.", + "publishDate": null, + "featuredImage": { + "imageUrl": null, + "credit": null, + "caption": null, + "altText": null, + "size": "actual", + "height": null, + "width": null + }, "description": null, "thumbnailURL": null }, @@ -14,6 +24,11 @@ "links": { "self": null } + }, + "blog": { + "links": { + "self": null + } } }, "meta": { diff --git a/packages/experiments-realm/BlogPost/mad-as-a-hatter.json b/packages/experiments-realm/BlogPost/mad-as-a-hatter.json index b463d803e1..56fc17e17a 100644 --- a/packages/experiments-realm/BlogPost/mad-as-a-hatter.json +++ b/packages/experiments-realm/BlogPost/mad-as-a-hatter.json @@ -2,10 +2,19 @@ "data": { "type": "card", "attributes": { - "title": "Mad As a Hatter", + "headline": "Mad As a Hatter", "slug": "mad-as-a-hatter", "body": "## Where it all begins\n\nThis is a story of a man named [Brady](https://eightiesforbrady.com), who was bringing up three very lovely girls under the rule of the Queen of Hearts.", "publishDate": "2034-11-20T18:00:00.000Z", + "featuredImage": { + "imageUrl": "https://images.unsplash.com/photo-1551422788-18e2a1f5bb88?q=80&w=3087&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", + "credit": null, + "caption": "We're all mad here.", + "altText": "", + "size": "contain", + "height": 400, + "width": null + }, "description": null, "thumbnailURL": "https://images.unsplash.com/photo-1551422788-18e2a1f5bb88?q=80&w=3087&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D" }, @@ -14,6 +23,11 @@ "links": { "self": "../Author/alice-enwunder" } + }, + "blog": { + "links": { + "self": null + } } }, "meta": { diff --git a/packages/experiments-realm/BlogPost/ultimate-guide-remote-work.json b/packages/experiments-realm/BlogPost/ultimate-guide-remote-work.json index 858eae7817..e4ad3ac3a6 100644 --- a/packages/experiments-realm/BlogPost/ultimate-guide-remote-work.json +++ b/packages/experiments-realm/BlogPost/ultimate-guide-remote-work.json @@ -2,10 +2,19 @@ "data": { "type": "card", "attributes": { - "title": "The Ultimate Guide to Remote Work", + "headline": "The Ultimate Guide to Remote Work", "slug": "ultimate-guide-remote-work", "body": "| Table of Contents |\n| ----------- | \n|
  1. [Introduction](#introduction)
  2. [Benefits of Remote Work](#benefits-of-remote-work)
  3. [Challenges and Solutions](#challenges-and-solutions)
  4. [Essential Tools for Remote Workers](#essential-tools-for-remote-workers)
  5. [Best Practices for Remote Work](#best-practices-for-remote-work)
  6. [Conclusion](#conclusion)
|\n\n

Introduction

\n\nRemote work has revolutionized the traditional workplace, offering flexibility and autonomy like never before. Whether you're a freelancer, an entrepreneur, or part of a company embracing flexible work arrangements, understanding how to make the most of remote work is crucial for success.\n\n

Benefits of Remote Work

\n\n### Flexibility\n\nOne of the most significant advantages of remote work is the ability to **work from anywhere**. Whether you prefer the comfort of your home, a bustling coffee shop, or a serene beach, the choice is yours.\n\n> \"The freedom to choose your workspace can greatly enhance creativity and job satisfaction.\" \n>\n> — Jane Doe, Remote Work Expert\n\n### Increased Productivity\n\nWithout the usual office distractions, many remote workers find they can **focus better** and **get more done** in less time.\n\n### Improved Work-Life Balance\n\nEliminating the daily commute gives you more time for personal activities, helping to reduce stress and improve overall well-being.\n\n
\n\"Work-life\n
Achieving a healthy work-life balance is easier with remote work.
\n
\n\n

Challenges and Solutions

\n\nWhile remote work offers many benefits, it also comes with its own set of challenges.\n\n### Overcoming Isolation\n\n**Challenge:** Feeling isolated from colleagues can lead to decreased motivation.\n\n**Solution:**\n- **Regular Check-Ins**: Schedule daily or weekly video calls with your team.\n- **Join Online Communities**: Engage with professionals in your field through forums or social media.\n\n> \"Staying connected is essential. Virtual coffee breaks can foster team spirit even when miles apart.\" \n>\n> — Remote Work Enthusiast\n\n### Effective Communication\n\n**Challenge**: Misunderstandings can occur without face-to-face interaction.\n\n**Solution**:\n- **Use Video Conferencing**: Tools like Zoom or Skype can help mimic in-person meetings.\n- **Clarify Expectations**: Be explicit in your communications to avoid confusion.\n\n### Time Management Strategies\n\n**Challenge**: Blurring lines between work and personal life can lead to burnout.\n\n**Solution**:\n1. **Set Boundaries**: Define your working hours and stick to them.\n2. **Take Regular Breaks**: Use techniques like the Pomodoro Technique to maintain focus.\n3. **Prioritize Tasks**: Create a to-do list and rank tasks by importance.\n\nHere's an example of a simple task list:\n- [x] Define working hours\n- [ ] Schedule regular breaks\n- [ ] Create daily to-do lists\n\n

Essential Tools for Remote Workers

\n\n### Communication Tools\n\n- **[Slack](https://slack.com/)**: For real-time messaging and collaboration.\n- **[Zoom](https://zoom.us/)**: For video conferencing and virtual meetings.\n- **[Microsoft Teams](https://www.microsoft.com/en-us/microsoft-teams/group-chat-software)**: Integrates with Office 365 for seamless collaboration.\n\n#### Sample Slack Configuration\n\n```json\n{\n \"channel\": \"#remote-work\",\n \"username\": \"JaneDoe\",\n \"icon_emoji\": \"💻\"\n}\n```\n\n### Project Management Tools\n\n- **[Trello](https://trello.com/)**: Organize tasks with boards and cards.\n- **[Asana](https://asana.com/)**: Manage projects and track progress.\n- **[Basecamp](https://basecamp.com/)**: Combines messaging, task management, and file storage.\n\n#### Trello Board Example\n\n- **To Do**\n - Write blog post\n- **In Progress**\n - Research remote work tools\n- **Done**\n - Set up home office\n\n### Productivity Apps\n\n- **[Todoist](https://todoist.com/)**: Keep track of tasks and deadlines.\n- **[RescueTime](https://www.rescuetime.com/)**: Monitor how you spend your time online.\n- **[Focus@Will](https://www.focusatwill.com/)**: Music designed to improve concentration.\n\n
\n\"Project\n
Utilizing the right tools can streamline your workflow.
\n
\n\n

Best Practices for Remote Work

\n\n### Setting Up a Workspace\n\n- **Choose the Right Spot**: Find a quiet area with minimal distractions.\n- **Ergonomic Furniture**: Invest in a good chair and desk to maintain proper posture.\n- **Proper Lighting**: Ensure your workspace is well-lit to reduce eye strain.\n\n> \"Your workspace should inspire you to do your best work every day.\" \n>\n> — Productivity Coach\n\n### Establishing a Routine\n\n- **Consistent Schedule**: Start and end work at the same times each day.\n- **Morning Rituals**: Activities like exercise or reading can prepare you mentally for the day.\n- **Plan Your Day**: Outline tasks and set achievable goals.\n\nHere's a sample morning routine:\n\n1. **6:30 AM**: Wake up and meditate.\n2. **7:00 AM**: Exercise for 30 minutes.\n3. **7:30 AM**: Have a healthy breakfast.\n4. **8:00 AM**: Start work.\n\n### Staying Connected\n\n- **Regular Updates**: Keep your team informed about your progress.\n- **Virtual Social Events**: Organize online gatherings to build camaraderie.\n- **Open Communication Channels**: Encourage feedback and discussions.\n\n

Conclusion

\n\nEmbracing remote work can lead to greater satisfaction and efficiency. By understanding the benefits and proactively addressing the challenges, you can create a fulfilling remote work experience.\n\n~~Working from the office every day is mandatory.~~ Remote work offers a flexible alternative that benefits both employers and employees.\n\n---\n\n*Interested in more tips? Check out our other articles on remote work and productivity.*\n", "publishDate": "2024-11-05T20:00:00.000Z", + "featuredImage": { + "imageUrl": "https://images.unsplash.com/photo-1614624532983-4ce03382d63d?q=80&w=3131&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", + "credit": "Image by Unsplash", + "caption": "Success in remote work is achievable with the right approach.", + "altText": "Desktop setup with laptop, monitor, keyboard and mouse, phone, headphones, and a cup of coffee", + "size": "actual", + "height": null, + "width": null + }, "description": "In today's digital age, remote work has transformed from a luxury to a necessity. This comprehensive guide will help you navigate the world of remote work, offering tips, tools, and best practices for success.", "thumbnailURL": "https://images.unsplash.com/photo-1614624532983-4ce03382d63d?q=80&w=3131&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D" }, diff --git a/packages/experiments-realm/BlogPost/urban-living-future-sustainable.json b/packages/experiments-realm/BlogPost/urban-living-future-sustainable.json index ff69a6cccb..b4ec40cfa4 100644 --- a/packages/experiments-realm/BlogPost/urban-living-future-sustainable.json +++ b/packages/experiments-realm/BlogPost/urban-living-future-sustainable.json @@ -2,10 +2,19 @@ "data": { "type": "card", "attributes": { - "title": "The Future of Urban Living: Skyscrapers or Sustainable Communities?", + "headline": "The Future of Urban Living: Skyscrapers or Sustainable Communities?", "slug": "urban-living-future-sustainable", "body": null, "publishDate": null, + "featuredImage": { + "imageUrl": null, + "credit": null, + "caption": null, + "altText": null, + "size": "actual", + "height": null, + "width": null + }, "description": "As our cities grow ever upward, should we continue to build, or focus on creating? This article explores the pros and cons of both approaches.", "thumbnailURL": "https://images.unsplash.com/photo-1548182880-8b7b2af2caa2?q=80&w=2970&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D" }, diff --git a/packages/experiments-realm/CatalogEntry/app.json b/packages/experiments-realm/CatalogEntry/app.json index 9d6fd1e2d9..bde984da5e 100644 --- a/packages/experiments-realm/CatalogEntry/app.json +++ b/packages/experiments-realm/CatalogEntry/app.json @@ -6,7 +6,7 @@ "description": "Catalog entry for AppCard", "isField": false, "ref": { - "module": "/catalog/app-card", + "module": "../app-card", "name": "AppCard" } }, diff --git a/packages/experiments-realm/CatalogEntry/fields/featured-image-field.json b/packages/experiments-realm/CatalogEntry/fields/featured-image-field.json new file mode 100644 index 0000000000..33a8ef0aed --- /dev/null +++ b/packages/experiments-realm/CatalogEntry/fields/featured-image-field.json @@ -0,0 +1,19 @@ +{ + "data": { + "type": "card", + "attributes": { + "title": "Featured Image Field", + "isField": true, + "ref": { + "module": "../../fields/featured-image", + "name": "FeaturedImageField" + } + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/catalog-entry", + "name": "CatalogEntry" + } + } + } +} diff --git a/packages/experiments-realm/Contact/a01fc5c9-d70d-4b9c-aae4-384cf2b79b25.json b/packages/experiments-realm/Contact/a01fc5c9-d70d-4b9c-aae4-384cf2b79b25.json index 0b7bb1a6f1..a76a866f81 100644 --- a/packages/experiments-realm/Contact/a01fc5c9-d70d-4b9c-aae4-384cf2b79b25.json +++ b/packages/experiments-realm/Contact/a01fc5c9-d70d-4b9c-aae4-384cf2b79b25.json @@ -4,23 +4,36 @@ "attributes": { "firstName": "David Paul", "lastName": "Jackson", + "position": null, "department": "IT", "primaryEmail": "david@gmail.com", - "secondaryEmail": "david@gmail.com", + "secondaryEmail": "david23232@gmail.com", "phoneMobile": { + "type": "office", "country": 1, "area": 415, - "phoneNumber": 1112222 + "number": 123456 }, "phoneOffice": { + "type": null, "country": null, "area": null, - "phoneNumber": null + "number": null }, "status": { "index": 1, "label": "Lead" }, + "socialLinks": [ + { + "label": "LinkedIn", + "value": "davidlinkedin.com" + }, + { + "label": "X", + "value": "x.com" + } + ], "description": null, "thumbnailURL": "https://images.pexels.com/photos/4571943/pexels-photo-4571943.jpeg?auto=compress&cs=tinysrgb&w=300&h=300&dpr=1" }, diff --git a/packages/experiments-realm/Customer/0e5aec99-798b-4417-9426-a338432e0ee5.json b/packages/experiments-realm/Customer/0e5aec99-798b-4417-9426-a338432e0ee5.json index ee58a7fad8..658afdf56b 100644 --- a/packages/experiments-realm/Customer/0e5aec99-798b-4417-9426-a338432e0ee5.json +++ b/packages/experiments-realm/Customer/0e5aec99-798b-4417-9426-a338432e0ee5.json @@ -2,34 +2,45 @@ "data": { "type": "card", "attributes": { - "name": "Sophia Nguyen", - "primaryEmail": null, - "secondaryEmail": null, + "firstName": "Elma", + "lastName": "Odsey", + "position": "Head of Recruitment", + "department": "app", + "primaryEmail": "elma@gmail.com", + "secondaryEmail": "elmaodsey@gmail.com", "phoneMobile": { - "country": null, - "area": null, - "phoneNumber": null + "type": "mobile", + "country": 6, + "area": 10, + "number": 3810923 }, "phoneOffice": { - "country": null, - "area": null, - "phoneNumber": null - }, - "socialLinks": { - "twitterURL": null, - "linkedInURL": null + "type": "office", + "country": 615, + "area": 724, + "number": 3789 }, "status": { - "index": null, - "label": null + "index": 0, + "label": "Customer" }, + "socialLinks": [ + { + "label": "LinkedIn", + "value": "https://www.google.com/" + }, + { + "label": "X", + "value": "https://www.google.com/" + } + ], "description": null, - "thumbnailURL": null + "thumbnailURL": "https://images.pexels.com/photos/1624229/pexels-photo-1624229.jpeg?auto=compress&cs=tinysrgb&w=300&h=300&dpr=2" }, "relationships": { "company": { "links": { - "self": "../CompanyCard/9bef6b44-dde1-46c7-8e18-12f754a4f32c" + "self": "../Company/9203c173-3420-44b3-863d-90defd7c57c8" } } }, diff --git a/packages/experiments-realm/Customer/67e87a6c-00d0-4c48-9318-f0c96daa4cae.json b/packages/experiments-realm/Customer/67e87a6c-00d0-4c48-9318-f0c96daa4cae.json index e3b02fefb0..3dfbfafb60 100644 --- a/packages/experiments-realm/Customer/67e87a6c-00d0-4c48-9318-f0c96daa4cae.json +++ b/packages/experiments-realm/Customer/67e87a6c-00d0-4c48-9318-f0c96daa4cae.json @@ -2,25 +2,40 @@ "data": { "type": "card", "attributes": { - "name": "Olivia Chen", - "primaryEmail": null, - "secondaryEmail": null, + "firstName": "Sophia", + "lastName": "Nguyen", + "position": "Sale Manager", + "department": "app", + "primaryEmail": "sophia@gmail.com", + "secondaryEmail": "sophiaNguyen@gmail.com", "phoneMobile": { - "country": null, - "area": null, - "phoneNumber": null + "type": "mobile", + "country": 1, + "area": 510, + "number": 5550145 }, "phoneOffice": { - "country": null, - "area": null, - "phoneNumber": null + "type": "office", + "country": 1, + "area": 510, + "number": 5552345 }, "status": { - "index": 1, - "label": "Lead" + "index": 0, + "label": "Customer" }, + "socialLinks": [ + { + "label": "LinkedIn", + "value": "https://www.google.com/" + }, + { + "label": "X", + "value": "https://www.google.com/" + } + ], "description": null, - "thumbnailURL": null + "thumbnailURL": "https://images.pexels.com/photos/4974417/pexels-photo-4974417.jpeg?auto=compress&cs=tinysrgb&w=300&h=300&dpr=2" }, "relationships": { "company": { @@ -36,4 +51,4 @@ } } } -} +} \ No newline at end of file diff --git a/packages/experiments-realm/Customer/7db76dab-19af-4069-9850-878fd54cfc2a.json b/packages/experiments-realm/Customer/7db76dab-19af-4069-9850-878fd54cfc2a.json deleted file mode 100644 index 91c2e14d3e..0000000000 --- a/packages/experiments-realm/Customer/7db76dab-19af-4069-9850-878fd54cfc2a.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "data": { - "type": "card", - "attributes": { - "name": null, - "primaryEmail": null, - "secondaryEmail": null, - "phoneMobile": { - "country": null, - "area": null, - "phoneNumber": null - }, - "phoneOffice": { - "country": null, - "area": null, - "phoneNumber": null - }, - "socialLinks": { - "twitterURL": null, - "linkedInURL": null - }, - "status": { - "index": null, - "label": null - }, - "description": null, - "thumbnailURL": null - }, - "relationships": { - "company": { - "links": { - "self": null - } - } - }, - "meta": { - "adoptsFrom": { - "module": "../crm/customer", - "name": "Customer" - } - } - } -} \ No newline at end of file diff --git a/packages/experiments-realm/Deal/4fdd3053-14f1-4800-827f-d3e2a5c44ae8.json b/packages/experiments-realm/Deal/4fdd3053-14f1-4800-827f-d3e2a5c44ae8.json index d3b67dcc53..bd5dbdf633 100644 --- a/packages/experiments-realm/Deal/4fdd3053-14f1-4800-827f-d3e2a5c44ae8.json +++ b/packages/experiments-realm/Deal/4fdd3053-14f1-4800-827f-d3e2a5c44ae8.json @@ -13,4 +13,4 @@ } } } -} +} \ No newline at end of file diff --git a/packages/experiments-realm/ExperimentsFieldsPreview/de720f57-964b-4a09-8d52-80cd8bb4b739.json b/packages/experiments-realm/ExperimentsFieldsPreview/de720f57-964b-4a09-8d52-80cd8bb4b739.json index 50f265f7ee..d241b9fcdb 100644 --- a/packages/experiments-realm/ExperimentsFieldsPreview/de720f57-964b-4a09-8d52-80cd8bb4b739.json +++ b/packages/experiments-realm/ExperimentsFieldsPreview/de720f57-964b-4a09-8d52-80cd8bb4b739.json @@ -7,6 +7,19 @@ "https://boxel/alice", "https://boxel/dan" ], + "location": { + "addressLine1": "285 Fulton Street ", + "addressLine2": null, + "city": "New York City", + "state": "NY", + "postalCode": "10007", + "country": { + "name": "United States of America", + "code": "US" + }, + "poBoxNumber": null + }, + "website": "https://cardstack.com/", "email": "alice@email.com", "emails": [ "alice@email.com", @@ -30,6 +43,35 @@ "value": "https://boxel/alice" } ], + "featuredImage": { + "imageUrl": "https://boxel-images.boxel.ai/app-assets/portraits/photo-1479936343636-73cdc5aae0c3.jpeg", + "credit": "Photo by Unsplash", + "caption": "It's a new day, it's a new life.", + "altText": "Woman smiling with the glow of sun in the background", + "size": "actual", + "height": null, + "width": 300 + }, + "images": [ + { + "imageUrl": "https://boxel-images.boxel.ai/app-assets/portraits/photo-1521119989659-a83eee488004.jpeg", + "credit": "Photo by Unsplash - Ut velit modi sed aliquid molestiae in unde voluptas.", + "caption": "Nam voluptatem nostrum qui aperiam rerum non similique porro sed iusto placeat cum sequi dolore. ", + "altText": "Portrait of man on a rooftop", + "size": "actual", + "height": null, + "width": null + }, + { + "imageUrl": "https://boxel-images.boxel.ai/app-assets/portraits/photo-1496345875659-11f7dd282d1d.jpeg", + "credit": "Photo by Unsplash - Quis quibusdam rem rerum maiores 33 galisum quidem.", + "caption": "Aut asperiores impedit nam aperiam dolore ex libero voluptate.", + "altText": "Man with dark sunglasses", + "size": "actual", + "height": null, + "width": null + } + ], "title": "Custom Field Preview", "description": null, "thumbnailURL": null diff --git a/packages/experiments-realm/Lead/1dbcc3e8-fe3c-4c4a-ba66-ff7d637a7358.json b/packages/experiments-realm/Lead/1dbcc3e8-fe3c-4c4a-ba66-ff7d637a7358.json index c71eab2e1e..a265f3e625 100644 --- a/packages/experiments-realm/Lead/1dbcc3e8-fe3c-4c4a-ba66-ff7d637a7358.json +++ b/packages/experiments-realm/Lead/1dbcc3e8-fe3c-4c4a-ba66-ff7d637a7358.json @@ -2,34 +2,45 @@ "data": { "type": "card", "attributes": { - "name": "Ethan Rodriguez", - "primaryEmail": null, - "secondaryEmail": null, + "firstName": "Ethan", + "lastName": "Smith", + "position": "Software Engineer", + "department": "app", + "primaryEmail": "ethan@gmail.com", + "secondaryEmail": "ethanSmith@gmail.com", "phoneMobile": { - "country": null, - "area": null, - "phoneNumber": null + "type": "mobile", + "country": 1, + "area": 510, + "number": 123456 }, "phoneOffice": { - "country": null, - "area": null, - "phoneNumber": null - }, - "socialLinks": { - "twitterURL": null, - "linkedInURL": null + "type": "office", + "country": 1, + "area": 510, + "number": 2442322 }, "status": { - "index": null, - "label": null + "index": 1, + "label": "Lead" }, + "socialLinks": [ + { + "label": "LinkedIn", + "value": "https://www.google.com/" + }, + { + "label": "X", + "value": "https://www.google.com/" + } + ], "description": null, - "thumbnailURL": null + "thumbnailURL": "https://images.pexels.com/photos/1681010/pexels-photo-1681010.jpeg?auto=compress&cs=tinysrgb&w=300&h=300&dpr=2" }, "relationships": { "company": { "links": { - "self": "../CompanyCard/9bef6b44-dde1-46c7-8e18-12f754a4f32c" + "self": "../Company/e1856419-27b0-4ce3-98fe-759d21c7144f" } } }, diff --git a/packages/experiments-realm/Lead/9d7b2f20-c7da-4ddc-8e77-b75d88c97b48.json b/packages/experiments-realm/Lead/9d7b2f20-c7da-4ddc-8e77-b75d88c97b48.json index 504146253b..e33b764cb3 100644 --- a/packages/experiments-realm/Lead/9d7b2f20-c7da-4ddc-8e77-b75d88c97b48.json +++ b/packages/experiments-realm/Lead/9d7b2f20-c7da-4ddc-8e77-b75d88c97b48.json @@ -2,34 +2,45 @@ "data": { "type": "card", "attributes": { - "name": "Liam O'Connor", - "primaryEmail": null, - "secondaryEmail": null, + "firstName": "Liam", + "lastName": "O'Connor", + "position": "Data Analystic", + "department": "app", + "primaryEmail": "liam@gmail.com", + "secondaryEmail": "liamOConnor@gmail.com", "phoneMobile": { - "country": null, - "area": null, - "phoneNumber": null + "type": "mobile", + "country": 1, + "area": 510, + "number": 2323423 }, "phoneOffice": { - "country": null, - "area": null, - "phoneNumber": null - }, - "socialLinks": { - "twitterURL": null, - "linkedInURL": null + "type": "office", + "country": 1, + "area": 510, + "number": 1231232 }, "status": { - "index": null, - "label": null + "index": 1, + "label": "Lead" }, + "socialLinks": [ + { + "label": "LinkedIn", + "value": "https://www.google.com/" + }, + { + "label": "X", + "value": "https://www.google.com/" + } + ], "description": null, - "thumbnailURL": null + "thumbnailURL": "https://images.pexels.com/photos/3785079/pexels-photo-3785079.jpeg?auto=compress&cs=tinysrgb&w=300&h=300&dpr=2" }, "relationships": { "company": { "links": { - "self": "../CompanyCard/b9ee2c7c-8e42-4c36-82ee-84f3536bb459" + "self": "../Company/e1856419-27b0-4ce3-98fe-759d21c7144f" } } }, @@ -40,4 +51,4 @@ } } } -} \ No newline at end of file +} diff --git a/packages/experiments-realm/ProductRequirementDocument/4165a295-e5ed-4922-a271-3fe49c79b482.json b/packages/experiments-realm/ProductRequirementDocument/4165a295-e5ed-4922-a271-3fe49c79b482.json index 86f5e8e2cc..094ca7f3fc 100644 --- a/packages/experiments-realm/ProductRequirementDocument/4165a295-e5ed-4922-a271-3fe49c79b482.json +++ b/packages/experiments-realm/ProductRequirementDocument/4165a295-e5ed-4922-a271-3fe49c79b482.json @@ -27,7 +27,7 @@ }, "meta": { "adoptsFrom": { - "module": "/catalog/product-requirement-document", + "module": "/experiments/product-requirement-document", "name": "ProductRequirementDocument" } } diff --git a/packages/experiments-realm/ProductRequirementDocument/6724b1e4-e620-4e62-86c4-4cab1c132170.json b/packages/experiments-realm/ProductRequirementDocument/6724b1e4-e620-4e62-86c4-4cab1c132170.json index a5e6999396..19d3a540e1 100644 --- a/packages/experiments-realm/ProductRequirementDocument/6724b1e4-e620-4e62-86c4-4cab1c132170.json +++ b/packages/experiments-realm/ProductRequirementDocument/6724b1e4-e620-4e62-86c4-4cab1c132170.json @@ -20,7 +20,7 @@ }, "meta": { "adoptsFrom": { - "module": "/catalog/product-requirement-document", + "module": "/experiments/product-requirement-document", "name": "ProductRequirementDocument" } } diff --git a/packages/experiments-realm/ProductRequirementDocument/8a2268d9-89f9-4d4c-ab56-dfb4471814a4.json b/packages/experiments-realm/ProductRequirementDocument/8a2268d9-89f9-4d4c-ab56-dfb4471814a4.json index 8de49344f4..85ad3f55a5 100644 --- a/packages/experiments-realm/ProductRequirementDocument/8a2268d9-89f9-4d4c-ab56-dfb4471814a4.json +++ b/packages/experiments-realm/ProductRequirementDocument/8a2268d9-89f9-4d4c-ab56-dfb4471814a4.json @@ -20,7 +20,7 @@ }, "meta": { "adoptsFrom": { - "module": "/catalog/product-requirement-document", + "module": "/experiments/product-requirement-document", "name": "ProductRequirementDocument" } } diff --git a/packages/catalog-realm/SkillCard/app-generator.json b/packages/experiments-realm/SkillCard/app-generator.json similarity index 86% rename from packages/catalog-realm/SkillCard/app-generator.json rename to packages/experiments-realm/SkillCard/app-generator.json index ed861d249a..4dc125c128 100644 --- a/packages/catalog-realm/SkillCard/app-generator.json +++ b/packages/experiments-realm/SkillCard/app-generator.json @@ -2,7 +2,7 @@ "data": { "type": "card", "attributes": { - "instructions": "The user has shared with you a product requirement document for an application they want to build. You must build that. Look at the domain for the area they are interested in and use your general knowledge to ensure data structures and the linkages between them are created.\n\nYou are a software engineer specializing in Boxel development. Boxel is a platform where people can create Cards, which under the hood are built out of glimmer components and ember. You are designed to assist users with code-related queries, troubleshooting, and best practices in this specific domain. You should ask for clarification when the user's query is ambiguous or lacks detail, but should also be able to make reasonable assumptions based on typical software engineering practices.\n\nIf the user wants to make something, they mostly want to create a Card. Cards are independent linkable items that get an ID. Fields are contained within cards, so sometimes a user wants a custom field (derived from FieldDef), but usually it's creating a card (derived from CardDef).\n\nUse typescript for the code. Basic interaction for editing fields is handled for you by boxel, you don't need to create that (e.g. StringField has an edit template that allows a user to edit the data). Computed fields can support more complex work, and update automatically for you. Interaction (button clicks, filtering on user typed content) will require work on templates that will happen elsewhere and is not yours to do.\n\nNever leave sections of code unfilled or with placeholders, finish all code you write.\n\nPut all classes in the same codeblock/file, and have all CardDefs exported (e.g. export class MyCard extends CardDef)\n\n\nYou have available:\n\nStringField\nMarkdownField\nNumberField\nBooleanField\nDateField\nDateTimeField\n\nConstruct any more complex data structures from these\n\nFields do not have default values.\n\nUse the () => format when *and only when* you need to define classes out of order\n\nEXAMPLE CODE, you MUST include the imports shown in this example :\n\n```gts\n\nimport { Component, CardDef, FieldDef, linksTo, linksToMany, field, contains, containsMany } from 'https://cardstack.com/base/card-api';\nimport StringField from 'https://cardstack.com/base/string';\nimport BooleanField from 'https://cardstack.com/base/boolean';\nimport DateField from 'https://cardstack.com/base/date';\nimport DateTimeField from 'https://cardstack.com/base/datetime';\nimport NumberField from 'https://cardstack.com/base/number';\n\nimport MarkdownField from 'https://cardstack.com/base/markdown';\n\n\n\nclass MyCustomField extends FieldDef {\n @field nestedField = contains(NumberField);\n @field nestedOtherField = contains(BooleanField);\n}\n\nexport class OutOfOrderDeclaration extends CardDef {\n static displayName = 'OutOfOrderDeclaration';\n\n @field linkToCardDefinedLaterInFile = linksTo(() => MyCustomCard);\n}\n\nexport class MyCustomCard extends CardDef {\n static displayName = 'BoxelBuddyGuestList';\n\n @field structuredData = contains(MyCustomField);\n\n // linksTo and linksToMany \n @field linkedData = linksToMany(AnotherCard);\n\n // A field that is computed from other data in the card\n @field computedData = contains(NumberField, {\n computeVia: function (this: MyCustomCard) {\n // implementation logic here\n return 1;\n },\n });\n \nexport class InOrderDeclaration extends CardDef {\n static displayName = 'InOrderDeclaration';\n\n @field linkToCardDefinedLaterInFile = linksTo(MyCustomCard);\n}\n\n}\n```\n\nImportant:\n\nIf a user is asking you to make, help or create something, assume they mean a boxel card unless they specifically request an image or logo.\n\n\nRemember to define a field the following syntax is used:\n\n @field fieldname = contains(FieldType);\n @field fieldname = containsMany(FieldType);\n\nAnd for linking to other cards:\n\n @field fieldname = linksTo(CardType);\n @field fieldname = linksToMany(CardType);\n\nYou must also write an app card. The app card definition should extend AppCard. Here is the code you must use to import the AppCard: `import { AppCard } from '/catalog/app-card';`\n\nYou can ask followups\n\nYou can propose new/improved data structures.\n\nTalk through the problem and structures, specifying how each should link to each other (this is very important), then write the code. \n\nYOU MUST CONSIDER LINKS BETWEEN THESE TYPES. Make sure common entities are extracted as their own types and that linksTo or linksToMany are used to connect the cards that need to be connected.\n\nPut it in a codeblock.\n\nRemember, if in the code a card class is used before it is defined, you must use the () => syntax. All CardDef classes should be exported classes.\n\nAsk the user if they want you to use the generated code to create a new app module.", + "instructions": "The user has shared with you a product requirement document for an application they want to build. You must build that. Look at the domain for the area they are interested in and use your general knowledge to ensure data structures and the linkages between them are created.\n\nYou are a software engineer specializing in Boxel development. Boxel is a platform where people can create Cards, which under the hood are built out of glimmer components and ember. You are designed to assist users with code-related queries, troubleshooting, and best practices in this specific domain. You should ask for clarification when the user's query is ambiguous or lacks detail, but should also be able to make reasonable assumptions based on typical software engineering practices.\n\nIf the user wants to make something, they mostly want to create a Card. Cards are independent linkable items that get an ID. Fields are contained within cards, so sometimes a user wants a custom field (derived from FieldDef), but usually it's creating a card (derived from CardDef).\n\nUse typescript for the code. Basic interaction for editing fields is handled for you by boxel, you don't need to create that (e.g. StringField has an edit template that allows a user to edit the data). Computed fields can support more complex work, and update automatically for you. Interaction (button clicks, filtering on user typed content) will require work on templates that will happen elsewhere and is not yours to do.\n\nNever leave sections of code unfilled or with placeholders, finish all code you write.\n\nPut all classes in the same codeblock/file, and have all CardDefs exported (e.g. export class MyCard extends CardDef)\n\n\nYou have available:\n\nStringField\nMarkdownField\nNumberField\nBooleanField\nDateField\nDateTimeField\n\nConstruct any more complex data structures from these\n\nFields do not have default values.\n\nUse the () => format when *and only when* you need to define classes out of order\n\nEXAMPLE CODE, you MUST include the imports shown in this example :\n\n```gts\n\nimport { Component, CardDef, FieldDef, linksTo, linksToMany, field, contains, containsMany } from 'https://cardstack.com/base/card-api';\nimport StringField from 'https://cardstack.com/base/string';\nimport BooleanField from 'https://cardstack.com/base/boolean';\nimport DateField from 'https://cardstack.com/base/date';\nimport DateTimeField from 'https://cardstack.com/base/datetime';\nimport NumberField from 'https://cardstack.com/base/number';\n\nimport MarkdownField from 'https://cardstack.com/base/markdown';\n\n\n\nclass MyCustomField extends FieldDef {\n @field nestedField = contains(NumberField);\n @field nestedOtherField = contains(BooleanField);\n}\n\nexport class OutOfOrderDeclaration extends CardDef {\n static displayName = 'OutOfOrderDeclaration';\n\n @field linkToCardDefinedLaterInFile = linksTo(() => MyCustomCard);\n}\n\nexport class MyCustomCard extends CardDef {\n static displayName = 'BoxelBuddyGuestList';\n\n @field structuredData = contains(MyCustomField);\n\n // linksTo and linksToMany \n @field linkedData = linksToMany(AnotherCard);\n\n // A field that is computed from other data in the card\n @field computedData = contains(NumberField, {\n computeVia: function (this: MyCustomCard) {\n // implementation logic here\n return 1;\n },\n });\n \nexport class InOrderDeclaration extends CardDef {\n static displayName = 'InOrderDeclaration';\n\n @field linkToCardDefinedLaterInFile = linksTo(MyCustomCard);\n}\n\n}\n```\n\nImportant:\n\nIf a user is asking you to make, help or create something, assume they mean a boxel card unless they specifically request an image or logo.\n\n\nRemember to define a field the following syntax is used:\n\n @field fieldname = contains(FieldType);\n @field fieldname = containsMany(FieldType);\n\nAnd for linking to other cards:\n\n @field fieldname = linksTo(CardType);\n @field fieldname = linksToMany(CardType);\n\nYou must also write an app card. The app card definition should extend AppCard. Here is the code you must use to import the AppCard: `import { AppCard } from '/experiments/app-card';`\n\nYou can ask followups\n\nYou can propose new/improved data structures.\n\nTalk through the problem and structures, specifying how each should link to each other (this is very important), then write the code. \n\nYOU MUST CONSIDER LINKS BETWEEN THESE TYPES. Make sure common entities are extracted as their own types and that linksTo or linksToMany are used to connect the cards that need to be connected.\n\nPut it in a codeblock.\n\nRemember, if in the code a card class is used before it is defined, you must use the () => syntax. All CardDef classes should be exported classes.\n\nAsk the user if they want you to use the generated code to create a new app module.", "title": "Boxel App Generator", "description": "Helps you generate code for a Boxel application", "thumbnailURL": null diff --git a/packages/catalog-realm/SkillCard/generate-product-requirements.json b/packages/experiments-realm/SkillCard/generate-product-requirements.json similarity index 99% rename from packages/catalog-realm/SkillCard/generate-product-requirements.json rename to packages/experiments-realm/SkillCard/generate-product-requirements.json index b653f72dc0..354093d40b 100644 --- a/packages/catalog-realm/SkillCard/generate-product-requirements.json +++ b/packages/experiments-realm/SkillCard/generate-product-requirements.json @@ -14,4 +14,4 @@ } } } -} \ No newline at end of file +} diff --git a/packages/experiments-realm/Tag/ad921cba-ffc7-4fdb-af34-ab8b93eae228.json b/packages/experiments-realm/Tag/ad921cba-ffc7-4fdb-af34-ab8b93eae228.json index b6ac85c13f..10ca6b584c 100644 --- a/packages/experiments-realm/Tag/ad921cba-ffc7-4fdb-af34-ab8b93eae228.json +++ b/packages/experiments-realm/Tag/ad921cba-ffc7-4fdb-af34-ab8b93eae228.json @@ -2,7 +2,7 @@ "data": { "type": "card", "attributes": { - "name": "Application", + "name": "App", "color": "", "description": null, "thumbnailURL": null diff --git a/packages/experiments-realm/Task/10402eaa-3826-4602-994b-60b7506fb98d.json b/packages/experiments-realm/Task/10402eaa-3826-4602-994b-60b7506fb98d.json index e38c820292..740b24eb9e 100644 --- a/packages/experiments-realm/Task/10402eaa-3826-4602-994b-60b7506fb98d.json +++ b/packages/experiments-realm/Task/10402eaa-3826-4602-994b-60b7506fb98d.json @@ -2,29 +2,25 @@ "data": { "type": "card", "attributes": { - "taskName": "Pill Picker Example", - "taskDetail": "Use multi-select but for pills", - "status": { - "index": 1, - "label": "Next Sprint" - }, "priority": { "index": 1, "label": "Medium" }, + "status": { + "completed": false, + "index": 1, + "label": "Next Sprint" + }, + "taskName": "Pill Picker Example", "dateRange": { - "start": "2024-11-14", - "end": "2024-12-15" + "start": "2024-12-08", + "end": "2024-12-10" }, + "taskDetail": "Use multi-select but for pills", "description": null, "thumbnailURL": null }, "relationships": { - "assignee": { - "links": { - "self": null - } - }, "project": { "links": { "self": "../Project/51ca069b-1c55-4648-8654-bd82bf162f9d" @@ -40,6 +36,11 @@ "self": null } }, + "assignee": { + "links": { + "self": null + } + }, "tags.0": { "links": { "self": "../Tag/ad921cba-ffc7-4fdb-af34-ab8b93eae228" @@ -63,4 +64,4 @@ } } } -} +} \ No newline at end of file diff --git a/packages/experiments-realm/Task/d21d407b-9c1c-4204-a3bb-7b91f9260017.json b/packages/experiments-realm/Task/d21d407b-9c1c-4204-a3bb-7b91f9260017.json index 451dbdf4b1..b4749f3aca 100644 --- a/packages/experiments-realm/Task/d21d407b-9c1c-4204-a3bb-7b91f9260017.json +++ b/packages/experiments-realm/Task/d21d407b-9c1c-4204-a3bb-7b91f9260017.json @@ -2,29 +2,25 @@ "data": { "type": "card", "attributes": { - "taskName": "Create RHS Sheet", - "taskDetail": null, - "status": { - "index": 0, - "label": "Backlog" - }, "priority": { "index": null, "label": null }, + "status": { + "completed": false, + "index": 0, + "label": "Backlog" + }, + "taskName": "Create RHS Sheet", "dateRange": { - "start": "2024-11-13", - "end": "2024-11-14" + "start": "2024-12-01", + "end": "2024-12-08" }, + "taskDetail": null, "description": null, "thumbnailURL": null }, "relationships": { - "assignee": { - "links": { - "self": "../TeamMember/4c9614cd-ecda-42cf-bc18-37903019a7be" - } - }, "project": { "links": { "self": null @@ -40,6 +36,11 @@ "self": null } }, + "assignee": { + "links": { + "self": "../TeamMember/4c9614cd-ecda-42cf-bc18-37903019a7be" + } + }, "tags": { "links": { "self": null diff --git a/packages/experiments-realm/address.gts b/packages/experiments-realm/address.gts index c247d39caf..4cdada8a6b 100644 --- a/packages/experiments-realm/address.gts +++ b/packages/experiments-realm/address.gts @@ -6,6 +6,8 @@ import { } from 'https://cardstack.com/base/card-api'; import StringField from 'https://cardstack.com/base/string'; import { CountryField } from './country'; +import MapPinIcon from '@cardstack/boxel-icons/map-pin'; +import { EntityDisplay } from './components/entity-display'; function getAddressRows( addressLine1: string | undefined, @@ -28,8 +30,26 @@ function getAddressRows( .map((r) => r.join(', ')); } +class Atom extends Component { + get label() { + return ( + [this.args.model?.city, this.args.model?.country?.code] + .filter(Boolean) + .join(', ') || '' + ); + } + +} + export class Address extends FieldDef { static displayName = 'Address'; + static icon = MapPinIcon; @field addressLine1 = contains(StringField); @field addressLine2 = contains(StringField); @field city = contains(StringField); @@ -75,4 +95,6 @@ export class Address extends FieldDef { }; + + static atom = Atom; } diff --git a/packages/catalog-realm/ai-app-generator.gts b/packages/experiments-realm/ai-app-generator.gts similarity index 100% rename from packages/catalog-realm/ai-app-generator.gts rename to packages/experiments-realm/ai-app-generator.gts diff --git a/packages/catalog-realm/app-card.gts b/packages/experiments-realm/app-card.gts similarity index 99% rename from packages/catalog-realm/app-card.gts rename to packages/experiments-realm/app-card.gts index 7de1124389..4fe422950d 100644 --- a/packages/catalog-realm/app-card.gts +++ b/packages/experiments-realm/app-card.gts @@ -346,7 +346,7 @@ class DefaultTabTemplate extends GlimmerComponent { ? { ...doc, meta: { - ...doc.meta, + ...doc.data.meta, realmURL: this.args.currentRealm, }, } diff --git a/packages/experiments-realm/author.gts b/packages/experiments-realm/author.gts index ae0abcaa57..de56facde9 100644 --- a/packages/experiments-realm/author.gts +++ b/packages/experiments-realm/author.gts @@ -1,21 +1,28 @@ -import StringCard from 'https://cardstack.com/base/string'; -import { Base64ImageField } from 'https://cardstack.com/base/base64-image'; +import { FeaturedImageField } from './fields/featured-image'; import MarkdownField from 'https://cardstack.com/base/markdown'; +import TextAreaField from 'https://cardstack.com/base/text-area'; import { Component, CardDef, field, contains, containsMany, + linksTo, + StringField, } from 'https://cardstack.com/base/card-api'; -import SquareUser from '@cardstack/boxel-icons/square-user'; import Email from '@cardstack/boxel-icons/mail'; import Linkedin from '@cardstack/boxel-icons/linkedin'; import XIcon from '@cardstack/boxel-icons/brand-x'; +import UserIcon from '@cardstack/boxel-icons/user'; +import UserRoundPen from '@cardstack/boxel-icons/user-round-pen'; + +import { cn, not } from '@cardstack/boxel-ui/helpers'; import { setBackgroundImage } from './components/layout'; import { ContactLinkField } from './fields/contact-link'; +import { BlogApp } from './blog-app'; +import { EmailField } from './email'; class AuthorContactLink extends ContactLinkField { static values = [ @@ -41,36 +48,145 @@ class AuthorContactLink extends ContactLinkField { } export class Author extends CardDef { - static displayName = 'Author Bio'; - static icon = SquareUser; - @field firstName = contains(StringCard); - @field lastName = contains(StringCard); - @field title = contains(StringCard, { + static displayName = 'Author'; + static icon = UserRoundPen; + @field firstName = contains(StringField); + @field lastName = contains(StringField); + @field title = contains(StringField, { computeVia: function (this: Author) { - return [this.firstName, this.lastName].filter(Boolean).join(' '); + let fullName = [this.firstName, this.lastName].filter(Boolean).join(' '); + return fullName.length ? fullName : 'Untitled Author'; }, + description: 'Full name of author', }); - @field description = contains(StringCard, { - computeVia: function (this: Author) { - return this.body; - }, + @field bio = contains(TextAreaField, { + description: 'Default author bio for embedded and isolated views.', + }); + @field fullBio = contains(MarkdownField, { + description: 'Full bio for isolated view', }); - @field photo = contains(Base64ImageField); - @field body = contains(MarkdownField); + @field quote = contains(TextAreaField); @field contactLinks = containsMany(AuthorContactLink); + @field email = contains(EmailField); + @field featuredImage = contains(FeaturedImageField); + @field blog = linksTo(BlogApp, { isUsed: true }); - static embedded = class Embedded extends Component { + static isolated = class Isolated extends Component { + }; + + static embedded = class Embedded extends Component { + }; static atom = class Atom extends Component { }; + + static fitted = class FittedTemplate extends Component { + + }; } diff --git a/packages/experiments-realm/blog-app.gts b/packages/experiments-realm/blog-app.gts index bdd33b4300..608738c9d4 100644 --- a/packages/experiments-realm/blog-app.gts +++ b/packages/experiments-realm/blog-app.gts @@ -19,6 +19,8 @@ import { CardError, getCard, SupportedMimeType, + type LooseSingleCardDocument, + relativeURL, } from '@cardstack/runtime-common'; import { type SortOption, @@ -85,7 +87,7 @@ const FILTERS: LayoutFilter[] = [ { displayName: 'Author Bios', icon: AuthorIcon, - cardTypeName: 'Author Bio', + cardTypeName: 'Author', createNewButtonText: 'Author', }, { @@ -337,10 +339,26 @@ class BlogAppTemplate extends Component { name: summary.id.substring(lastIndex + 1), }; filter.cardRef = cardRef; + + let realmUrl = this.args.model[realmURL]; + if (!this.args.model.id || !realmUrl?.href) { + throw new Error(`Missing card id or realm url`); + } + let relativeTo = relativeURL( + new URL(this.args.model.id), + new URL(`${cardRef.module}/${cardRef.name}`), + realmUrl, + ); + if (!relativeTo) { + throw new Error(`Missing relative url`); + } filter.query = { filter: { on: cardRef, - eq: { 'blog.id': this.args.model.id! }, + any: [ + { eq: { 'blog.id': this.args.model.id } }, + { eq: { 'blog.id': relativeTo } }, + ], }, }; } @@ -369,8 +387,24 @@ class BlogAppTemplate extends Component { return; } let currentRealm = this.realms[0]; + let doc: LooseSingleCardDocument = { + data: { + type: 'card', + relationships: { + blog: { + links: { + self: this.args.model.id!, + }, + }, + }, + meta: { + adoptsFrom: ref, + }, + }, + }; await this.args.context?.actions?.createCard?.(ref, currentRealm, { realmURL: currentRealm, + doc, }); }); } @@ -418,7 +452,8 @@ export class BlogApp extends CardDef { } .fitted-blog :deep(.card-title) { -webkit-line-clamp: 2; - font-weight: 600; + font: 600 var(--boxel-font-sm); + letter-spacing: var(--boxel-lsp-xs); } .fitted-blog :deep(.card-display-name) { margin: 0; diff --git a/packages/experiments-realm/blog-post.gts b/packages/experiments-realm/blog-post.gts index 3282b74558..704dd3e756 100644 --- a/packages/experiments-realm/blog-post.gts +++ b/packages/experiments-realm/blog-post.gts @@ -1,3 +1,4 @@ +import { FeaturedImageField } from './fields/featured-image'; import DatetimeField from 'https://cardstack.com/base/datetime'; import StringField from 'https://cardstack.com/base/string'; import MarkdownField from 'https://cardstack.com/base/markdown'; @@ -19,13 +20,15 @@ class EmbeddedTemplate extends Component {