solid wow

This commit is contained in:
Brendan Allan 2023-12-19 17:12:25 +08:00
parent 0543216eef
commit 292ae97b5f
41 changed files with 2210 additions and 1399 deletions

View file

@ -1,4 +1,5 @@
{
"name": "publish-artifacts",
"private": true,
"scripts": {
"build": "ncc build index.ts --minify"

View file

@ -12,8 +12,8 @@
},
"dependencies": {
"@remix-run/router": "^1.14.0",
"@rspc/client": "0.0.0-main-45466c86",
"@rspc/tauri": "0.0.0-main-45466c86",
"@rspc/client": "0.0.0-main-b8b35d28",
"@rspc/tauri": "0.0.0-main-b8b35d28",
"@sd/client": "workspace:*",
"@sd/interface": "workspace:*",
"@sd/ui": "workspace:*",

View file

@ -4,7 +4,7 @@
},
"build": {
"distDir": "../dist",
"devPath": "http://localhost:8001",
"devPath": "http://localhost:4321",
"beforeDevCommand": "pnpm dev",
"beforeBuildCommand": "pnpm turbo run build --filter=@sd/desktop..."
},

View file

@ -24,8 +24,8 @@
"@react-navigation/drawer": "^6.6.6",
"@react-navigation/native": "^6.1.9",
"@react-navigation/stack": "^6.3.20",
"@rspc/client": "0.0.0-main-45466c86",
"@rspc/react": "0.0.0-main-45466c86",
"@rspc/client": "0.0.0-main-b8b35d28",
"@rspc/react": "0.0.0-main-b8b35d28",
"@sd/assets": "workspace:*",
"@sd/client": "workspace:*",
"@shopify/flash-list": "1.4.3",

View file

@ -14,9 +14,10 @@
"@astrojs/react": "^3.0.7",
"@astrojs/solid-js": "^3.0.2",
"@astrojs/tailwind": "^5.0.3",
"@rspc/solid": "0.0.0-main-45466c86",
"@rspc/solid": "0.0.0-main-b8b35d28",
"@sd/assets": "workspace:^",
"@sd/client": "workspace:^",
"@sd/ui": "workspace:^",
"@solid-primitives/event-listener": "^2.3.0",
"@solid-primitives/intersection-observer": "^2.1.3",
"@solid-primitives/resize-observer": "^2.0.22",

View file

@ -1,7 +1,7 @@
import { createContext, useContext, type ParentProps } from 'solid-js';
import { Ordering } from './store';
import { type CreateExplorer } from './useExplorer';
import { type CreateExplorer } from './createExplorer';
import { type Ordering } from './store';
/**
* Context that must wrap anything to do with the explorer.

View file

@ -1,6 +1,15 @@
import { createEventListener } from '@solid-primitives/event-listener';
import { createResizeObserver } from '@solid-primitives/resize-observer';
import clsx from 'clsx';
import { createEffect, createSignal, splitProps, type ComponentProps } from 'solid-js';
import {
createEffect,
createMemo,
createSignal,
JSX,
Show,
splitProps,
type ComponentProps
} from 'solid-js';
// import { Tooltip } from '@sd/ui';
@ -159,8 +168,8 @@ export function RenameTextBox(props: RenameTextBoxProps) {
ref={ref!}
role="textbox"
contentEditable={allowRename()}
className={clsx(
'cursor-default overflow-hidden rounded-md px-1.5 py-px text-xs text-ink outline-none',
class={clsx(
'cursor-default overflow-hidden rounded-md px-1.5 py-px text-center text-xs text-ink outline-none',
allowRename() && 'whitespace-normal bg-app !text-ink ring-2 ring-accent-deep',
!allowRename && props.idleClassName,
props.class
@ -183,33 +192,116 @@ export function RenameTextBox(props: RenameTextBoxProps) {
onKeyDown={handleKeyDown}
{...wrapperProps}
>
{props.name}
{/* {allowRename ? (
name
) : (
<TruncatedText text={name} lines={lines} onTruncate={setIsTruncated} />
)} */}
{allowRename()
? props.name
: (() => {
const ellipsis = createMemo(() => {
const extension = props.name.lastIndexOf('.');
if (extension !== -1)
return `...${props.name.slice(
-Math.min(props.name.length - extension + 2, 8)
)}`;
return `...${props.name.slice(-8)}`;
});
return (
<TruncatedText
lines={props.lines ?? 2}
postfix={ellipsis()}
onTruncate={setIsTruncated}
>
{props.name}
</TruncatedText>
);
})()}
</div>
// </Tooltip>
);
}
interface TruncatedTextProps {
text: string;
lines?: number;
onTruncate: (wasTruncated: boolean) => void;
}
const LINE_HEIGHT = 19;
function TruncatedText(props: TruncatedTextProps) {
const ellipsis = () => {
const extension = props.text.lastIndexOf('.');
if (extension !== -1) return `...${props.text.slice(-(props.text.length - extension + 2))}`;
return `...${props.text.slice(-8)}`;
};
function TruncatedText(props: {
lines: number;
prefix?: JSX.Element;
postfix?: JSX.Element;
children: string;
style?: JSX.CSSProperties;
onTruncate?: (wasTruncated: boolean) => void;
}) {
const [cutoff, setCutoff] = createSignal<Array<'left' | 'right'>>([]);
const cutoffChildren = createMemo(() => {
const length = props.children.length;
let cursor = length;
const cutoffsArray = cutoff();
for (let i = 1; i <= cutoffsArray.length; i++) {
const delta = Math.ceil(length * Math.pow(0.5, i));
const cutoff = cutoffsArray[i]!;
cursor += (cutoff === 'left' ? -1 : 1) * delta;
}
return props.children.slice(0, cursor);
});
let ref!: HTMLDivElement;
let currentlyTruncating = false;
const fits = createMemo(
() => ref?.getBoundingClientRect().height ?? 0 / LINE_HEIGHT <= props.lines
);
function truncate() {
if (fits()) {
setCutoff((c) => [...c, 'right' as const]);
return (currentlyTruncating = false);
}
setCutoff((c) => [...c, 'left' as const]);
if (fits()) {
return (currentlyTruncating = false);
}
currentlyTruncating = true;
truncate();
}
function reset() {
setCutoff([]);
if (fits()) return;
currentlyTruncating = true;
truncate();
}
createResizeObserver(
() => ref,
() => {
if (currentlyTruncating) return;
reset();
}
);
return (
<TruncateMarkup lines={props.lines} ellipsis={ellipsis} onTruncate={props.onTruncate}>
<div>{props.text}</div>
</TruncateMarkup>
<div
style={{
'word-break': 'break-word',
...props.style
}}
ref={ref}
>
<Show when={props.prefix}>
<div style={{ display: 'inline-block' }}>{props.prefix}</div>
</Show>
{cutoffChildren()}
{/* <Show when={cutoff().length > 0}>{props.postfix}</Show> */}
</div>
);
}

View file

@ -1,14 +1,23 @@
import { getIcon, getIconByName } from '@sd/assets/util';
import { createElementSize } from '@solid-primitives/resize-observer';
import clsx from 'clsx';
import { createMemo, createSignal, Match, Show, Switch, type ComponentProps } from 'solid-js';
import {
createEffect,
createMemo,
createSignal,
Match,
Show,
Switch,
type ComponentProps
} from 'solid-js';
import { createStore } from 'solid-js/store';
import { getExplorerItemData, type ExplorerItem } from '@sd/client';
import { usePlatform } from '../../Platform';
import { LayeredFileIcon } from './LayeredFileIcon';
import classes from './Thumb.module.scss';
interface FileThumbProps extends Pick<ComponentProps<'img'>, 'ref'> {
interface FileThumbProps extends Pick<ComponentProps<'img'>, 'ref' | 'class'> {
data: ExplorerItem;
loadOriginal?: boolean;
size?: number;
@ -21,7 +30,6 @@ interface FileThumbProps extends Pick<ComponentProps<'img'>, 'ref'> {
extension?: boolean;
mediaControls?: boolean;
pauseVideo?: boolean;
className?: string;
frameClassName?: string;
childClassName?: string | ((type: ThumbType) => string | undefined);
isSidebarPreview?: boolean;
@ -51,11 +59,13 @@ export function FileThumb(props: FileThumbProps) {
return { variant: 'icon' };
});
const platform = usePlatform();
const src = createMemo<string | undefined>(() => {
switch (thumbType().variant) {
case 'thumbnail':
// if (itemData().thumbnailKey.length > 0)
// return platform.getThumbnailUrlByThumbKey(itemData.thumbnailKey);
if (itemData().thumbnailKey.length > 0)
return platform.getThumbnailUrlByThumbKey(itemData().thumbnailKey);
break;
case 'icon':
@ -80,11 +90,12 @@ export function FileThumb(props: FileThumbProps) {
: props.childClassName;
const childClassName = 'max-h-full max-w-full object-contain';
const frameClassName = clsx(
'rounded-sm border-2 border-app-line bg-app-darkBox',
props.frameClassName,
true ? classes.checkers : classes.checkersLight
);
const frameClassName = () =>
clsx(
'rounded-sm border-2 border-app-line bg-app-darkBox',
props.frameClassName,
true ? classes.checkers : classes.checkersLight
);
const getClass = () => clsx(childClassName, _childClassName());
@ -109,60 +120,79 @@ export function FileThumb(props: FileThumbProps) {
};
return (
<Show when={src()}>
{(src) => (
<Switch>
<Match when={thumbType().variant === 'thumbnail'}>
<Thumbnail
{...props.childProps}
ref={props.ref}
src={src()}
cover={props.cover}
onLoad={() => onLoad('thumbnail')}
onError={(e) => onError('thumbnail', e)}
decoding={props.size ? 'async' : 'sync'}
class={clsx(
props.cover
? [
'min-h-full min-w-full object-cover object-center',
_childClassName()
]
: getClass(),
props.frame &&
!(itemData().kind === 'Video' && props.blackBars) &&
frameClassName
)}
crossOrigin="anonymous" // Here it is ok, because it is not a react attr
blackBars={
props.blackBars && itemData().kind === 'Video' && !props.cover
}
blackBarsSize={props.blackBarsSize}
extension={
props.extension &&
itemData().extension &&
itemData().kind === 'Video'
? itemData().extension || undefined
: undefined
}
/>
</Match>
<Match when={thumbType().variant === 'icon'}>
<LayeredFileIcon
{...props.childProps}
ref={props.ref}
src={src()}
kind={itemData().kind}
extension={itemData().extension}
onLoad={() => onLoad('icon')}
onError={(e) => onError('icon', e)}
decoding={props.size ? 'async' : 'sync'}
class={getClass()}
draggable={false}
/>
</Match>
</Switch>
<div
style={{
...(props.size
? {
maxWidth: props.size.toString(),
width: props.size.toString(),
height: props.size.toString()
}
: {})
}}
class={clsx(
'relative flex shrink-0 items-center justify-center',
// !loaded && 'invisible',
!props.size && 'h-full w-full',
props.cover && 'overflow-hidden',
props.class
)}
</Show>
>
<Show when={src()}>
{(src) => (
<Switch>
<Match when={thumbType().variant === 'thumbnail'}>
<Thumbnail
{...props.childProps}
ref={props.ref}
src={src()}
cover={props.cover}
onLoad={() => onLoad('thumbnail')}
onError={(e) => onError('thumbnail', e)}
decoding={props.size ? 'async' : 'sync'}
class={clsx(
props.cover
? [
'min-h-full min-w-full object-cover object-center',
_childClassName()
]
: getClass(),
props.frame &&
!(itemData().kind === 'Video' && props.blackBars) &&
frameClassName()
)}
crossOrigin="anonymous" // Here it is ok, because it is not a react attr
blackBars={
props.blackBars && itemData().kind === 'Video' && !props.cover
}
blackBarsSize={props.blackBarsSize}
extension={
props.extension &&
itemData().extension &&
itemData().kind === 'Video'
? itemData().extension || undefined
: undefined
}
/>
</Match>
<Match when={thumbType().variant === 'icon'}>
<LayeredFileIcon
{...props.childProps}
ref={props.ref}
src={src()}
kind={itemData().kind}
extension={itemData().extension}
onLoad={() => onLoad('icon')}
onError={(e) => onError('icon', e)}
decoding={props.size ? 'async' : 'sync'}
class={getClass()}
draggable={false}
/>
</Match>
</Switch>
)}
</Show>
</div>
);
}
@ -180,7 +210,14 @@ function Thumbnail(props: ThumbnailProps) {
return (
<>
<img ref={setRef} draggable={false} />
<img
{...props}
class={props.class}
style={props.style}
src={props.src}
ref={setRef}
draggable={false}
/>
{(props.cover || (size.width && size.width > 80)) && props.extension && (
<div
style={{

View file

@ -1,84 +1,145 @@
// import getLineHeight from 'line-height';
// import * as Solid from 'solid-js';
import { createResizeObserver } from '@solid-primitives/resize-observer';
import getLineHeight from 'line-height';
import * as Solid from 'solid-js';
// export interface TruncateMarkupProps {
// lines: number;
// ellipsis?: Solid.JSX.Element | ((element: Solid.JSX.Element) => Solid.JSX.Element);
// children: (ref: HTMLDivElement, style: any) => Solid.JSX.Element;
// lineHeight?: number | string;
// tokenize?: string;
// onTruncate?: (wasTruncated: boolean) => any;
// }
export interface TruncateMarkupProps {
lines: number;
ellipsis?: Solid.JSX.Element | ((element: Solid.JSX.Element) => Solid.JSX.Element);
children: (props: { ref: HTMLDivElement; style: any }) => Solid.JSX.Element;
lineHeight?: number | string;
tokenize?: string;
onTruncate?: (wasTruncated: boolean) => any;
}
// export function TruncateMarkup(props: TruncateMarkupProps) {
// let splitDirectionSeq = [];
// let shouldTruncate = true;
// let wasLastCharTested = false;
// let endFound = false;
// let latestThatFits = null;
// let onTruncateCalled = false;
export function TruncateMarkup(props: Solid.ParentProps<TruncateMarkupProps>) {
const [text, setText] = Solid.createSignal('');
// let el: HTMLDivElement;
const children = Solid.children(() => props.children);
// Solid.onMount(() => {
// // if (!isValid) return;
const [ref, setRef] = Solid.createSignal<HTMLElement | null>(null);
// let splitDirectionSeq: Array<'left' | 'right'> = [];
// let shouldTruncate = true;
// let wasLastCharTested = false;
// let endFound = false;
// let latestThatFits = null;
// let onTruncateCalled = false;
let shouldTruncate = false;
let latestThatFits = null;
let onTruncateCalled = false;
let lineHeight: any = null;
let endFound = false;
let splitDirectionSeq = [];
let wasLastCharTested = false;
// const lineHeight = props.lineHeight || getLineHeight(el);
type SplitDirection = 'left' | 'right';
// const fits = Solid.createMemo(() => {
// const maxLines = props.lines;
// const { height } = el!.getBoundingClientRect();
// const computedLines = Math.round(height / parseFloat(lineHeight));
function splitString(string: string, splitDirections: Array<SplitDirection>, level: any) {
if (!splitDirections.length) return string;
// return maxLines >= computedLines;
// });
if (splitDirections.length && policy.isAtomic(string)) {
if (!wasLastCharTested) wasLastCharTested = true;
else endFound = true;
// function onTruncate(wasTruncated: boolean) {
// if (!onTruncateCalled) {
// onTruncateCalled = true;
// props.onTruncate?.(wasTruncated);
// }
// }
return string;
}
// function truncateOriginalText() {
// endFound = false;
// splitDirectionSeq = ['left'];
// wasLastCharTested = false;
if (policy.tokenizeString) {
const wordsArray = splitArray(policy.tokenizeString(string), splitDirections, level);
// tryToFit(origText, splitDirectionSeq);
// }
return wordsArray.joing('');
}
const [splitDirection, ...restSplitDirections] = splitDirections;
const pivotIndex = Math.ceil(string.length / 2);
const beforeString = string.substring(0, pivotIndex);
// function tryToFit() {}
if (splitDirection === 'left') return splitString(beforeString, restSplitDirections, level);
// function truncate() {
// if (fits()) {
// shouldTruncate = false;
// onTruncate(false);
const afterString = string.substring(pivotIndex);
// return;
// }
return beforeString + splitString(afterString, restSplitDirections, level);
}
// truncateOriginalText();
// }
// });
function splitArray(array: string[], splitDirections: Array<SplitDirection>, level) {
if (!splitDirections.length) {
return array;
}
// function childrenElementWithRef() {
// const childrenArray = children.toArray();
// if (childrenArray.length > 1) {
// throw new Error('TruncateMarkup must have only one child element');
// }
if (array.length === 1) {
return [split(array[0]!, splitDirections, /* isRoot */ false, level)];
}
// const child = childrenArray[0];
// }
const [splitDirection, ...restSplitDirections] = splitDirections;
const pivotIndex = Math.ceil(array.length / 2);
const beforeArray = array.slice(0, pivotIndex);
// const [text, setText] = Solid.createSignal(props.children);
if (splitDirection === 'left') return splitArray(beforeArray, restSplitDirections, level);
// return <>{text()}</>;
// }
const afterArray = array.slice(pivotIndex);
return beforeArray.concat(splitArray(afterArray, restSplitDirections, level));
}
function split(
node: HTMLElement | string | null,
splitDirections: Array<SplitDirection>,
isRoot = false,
level = 1
): HTMLElement | string | null {
if (!node) {
endFound = true;
return node;
} else if (typeof node === 'string') {
return splitString(node, splitDirections, level);
} else if (node.nodeType === node.TEXT_NODE) {
return splitString(node.textContent ?? '', splitDirections, level);
}
return node;
}
function tryToFit(rootEl: HTMLElement, splitDirections: Array<'left' | 'right'>) {
if (!rootEl.firstChild) {
// no markup in container
return;
}
const newRootEL = split(rootEl, splitDirections, true);
}
function fits() {
const refValue = ref();
if (!refValue) return false;
const { height } = refValue.getBoundingClientRect();
const computedLines = Math.round(height / parseFloat(lineHeight));
return props.lines >= computedLines;
}
function truncateOriginalText() {
endFound = false;
splitDirectionSeq = ['left'];
wasLastCharTested = false;
tryToFit(origText, splitDirectionSeq);
}
function truncate() {
if (fits()) {
shouldTruncate = false;
onTruncate(false);
return;
}
truncateOriginalText();
}
createResizeObserver(ref, () => {
shouldTruncate = false;
latestThatFits = null;
setText(origText);
shouldTruncate = true;
onTruncateCalled = false;
truncate();
});
return text;
}

View file

@ -1,479 +1,498 @@
// import getLineHeight from 'line-height';
// import memoizeOne from 'memoize-one';
// import PropTypes from 'prop-types';
// import React from 'react';
// import ResizeObserver from 'resize-observer-polyfill';
// import { Atom, ATOM_STRING_ID, isAtomComponent } from './atom';
// import TOKENIZE_POLICY from './tokenize-rules';
// const SPLIT = {
// LEFT: true,
// RIGHT: false
// };
// const toString = (node, string = '') => {
// if (!node) {
// return string;
// } else if (typeof node === 'string') {
// return string + node;
// } else if (isAtomComponent(node)) {
// return string + ATOM_STRING_ID;
// }
// const children = Array.isArray(node) ? node : node.props.children || '';
// return string + React.Children.map(children, (child) => toString(child)).join('');
// };
// const cloneWithChildren = (node, children, isRootEl, level) => {
// const getDisplayStyle = () => {
// if (isRootEl) {
// return {
// // root element cannot be an inline element because of the line calculation
// display: (node.props.style || {}).display || 'block'
// };
// } else if (level === 2) {
// return {
// // level 2 elements (direct children of the root element) need to be inline because of the ellipsis.
// // if level 2 element was a block element, ellipsis would get rendered on a new line, breaking the max number of lines
// display: (node.props.style || {}).display || 'inline-block'
// };
// } else return {};
// };
// return {
// ...node,
// props: {
// ...node.props,
// style: {
// ...node.props.style,
// ...getDisplayStyle()
// },
// children
// }
// };
// };
// const validateTree = (node) => {
// if (node == null || ['string', 'number'].includes(typeof node) || isAtomComponent(node)) {
// return true;
// } else if (typeof node.type === 'function') {
// if (process.env.NODE_ENV !== 'production') {
// /* eslint-disable no-console */
// console.error(
// `ReactTruncateMarkup tried to render <${node.type.name} />, but truncating React components is not supported, the full content is rendered instead. Only DOM elements are supported. Alternatively, you can take advantage of the <TruncateMarkup.Atom /> component (see more in the docs https://github.com/patrik-piskay/react-truncate-markup/blob/master/README.md#truncatemarkupatom-).`
// );
// /* eslint-enable */
// }
// return false;
// }
// if (node.props && node.props.children) {
// return React.Children.toArray(node.props.children).reduce(
// (isValid, child) => isValid && validateTree(child),
// true
// );
// }
// return true;
// };
// export default class TruncateMarkup extends React.Component {
// static Atom = Atom;
// static propTypes = {
// children: PropTypes.element.isRequired,
// lines: PropTypes.number,
// ellipsis: PropTypes.oneOfType([PropTypes.element, PropTypes.string, PropTypes.func]),
// lineHeight: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
// onTruncate: PropTypes.func,
// // eslint-disable-next-line
// onAfterTruncate: (props, propName, componentName) => {
// if (props[propName]) {
// return new Error(
// `${componentName}: Setting \`onAfterTruncate\` prop is deprecated, use \`onTruncate\` instead.`
// );
// }
// },
// tokenize: (props, propName, componentName) => {
// const tokenizeValue = props[propName];
// if (typeof tokenizeValue !== 'undefined') {
// if (!TOKENIZE_POLICY[tokenizeValue]) {
// /* eslint-disable no-console */
// return new Error(
// `${componentName}: Unknown option for prop 'tokenize': '${tokenizeValue}'. Option 'characters' will be used instead.`
// );
// /* eslint-enable */
// }
// }
// }
// };
// static defaultProps = {
// lines: 1,
// ellipsis: '...',
// lineHeight: '',
// onTruncate: () => {},
// tokenize: 'characters'
// };
// constructor(props) {
// super(props);
// this.state = {
// text: this.childrenWithRefMemo(this.props.children)
// };
// }
// lineHeight = null;
// splitDirectionSeq = [];
// shouldTruncate = true;
// wasLastCharTested = false;
// endFound = false;
// latestThatFits = null;
// onTruncateCalled = false;
// toStringMemo = memoizeOne(toString);
// childrenWithRefMemo = memoizeOne(this.childrenElementWithRef);
// validateTreeMemo = memoizeOne(validateTree);
// get isValid() {
// return this.validateTreeMemo(this.props.children);
// }
// get origText() {
// return this.childrenWithRefMemo(this.props.children);
// }
// get policy() {
// return TOKENIZE_POLICY[this.props.tokenize] || TOKENIZE_POLICY.characters;
// }
// componentDidMount() {
// if (!this.isValid) {
// return;
// }
// // get the computed line-height of the parent element
// // it'll be used for determining whether the text fits the container or not
// this.lineHeight = this.props.lineHeight || getLineHeight(this.el);
// this.truncate();
// }
// UNSAFE_componentWillReceiveProps(nextProps) {
// this.shouldTruncate = false;
// this.latestThatFits = null;
// this.setState(
// {
// text: this.childrenWithRefMemo(nextProps.children)
// },
// () => {
// if (!this.isValid) {
// return;
// }
// this.lineHeight = nextProps.lineHeight || getLineHeight(this.el);
// this.shouldTruncate = true;
// this.truncate();
// }
// );
// }
// componentDidUpdate() {
// if (this.shouldTruncate === false || this.isValid === false) {
// return;
// }
// if (this.endFound) {
// // we've found the end where we cannot split the text further
// // that means we've already found the max subtree that fits the container
// // so we are rendering that
// if (this.latestThatFits !== null && this.state.text !== this.latestThatFits) {
// /* eslint-disable react/no-did-update-set-state */
// this.setState({
// text: this.latestThatFits
// });
// return;
// /* eslint-enable */
// }
// this.onTruncate(/* wasTruncated */ true);
// return;
// }
// if (this.splitDirectionSeq.length) {
// if (this.fits()) {
// this.latestThatFits = this.state.text;
// // we've found a subtree that fits the container
// // but we need to check if we didn't cut too much of it off
// // so we are changing the last splitting decision from splitting and going left
// // to splitting and going right
// this.splitDirectionSeq.splice(
// this.splitDirectionSeq.length - 1,
// 1,
// SPLIT.RIGHT,
// SPLIT.LEFT
// );
// } else {
// this.splitDirectionSeq.push(SPLIT.LEFT);
// }
// this.tryToFit(this.origText, this.splitDirectionSeq);
// }
// }
// componentWillUnmount() {
// this.lineHeight = null;
// this.latestThatFits = null;
// this.splitDirectionSeq = [];
// }
// onTruncate = (wasTruncated) => {
// if (!this.onTruncateCalled) {
// this.onTruncateCalled = true;
// this.props.onTruncate(wasTruncated);
// }
// };
// handleResize = (el, prevResizeObserver) => {
// // clean up previous observer
// if (prevResizeObserver) {
// prevResizeObserver.disconnect();
// }
// // unmounting or just unsetting the element to be replaced with a new one later
// if (!el) return null;
// /* Wrapper element resize handing */
// let initialRender = true;
// const resizeCallback = () => {
// if (initialRender) {
// // ResizeObserer cb is called on initial render too so we are skipping here
// initialRender = false;
// } else {
// // wrapper element has been resized, recalculating with the original text
// this.shouldTruncate = false;
// this.latestThatFits = null;
// this.setState(
// {
// text: this.origText
// },
// () => {
// this.shouldTruncate = true;
// this.onTruncateCalled = false;
// this.truncate();
// }
// );
// }
// };
// const resizeObserver = prevResizeObserver || new ResizeObserver(resizeCallback);
// resizeObserver.observe(el);
// return resizeObserver;
// };
// truncate() {
// if (this.fits()) {
// // the whole text fits on the first try, no need to do anything else
// this.shouldTruncate = false;
// this.onTruncate(/* wasTruncated */ false);
// return;
// }
// this.truncateOriginalText();
// }
// setRef = (el) => {
// const isNewEl = this.el !== el;
// this.el = el;
// // whenever we obtain a new element, attach resize handler
// if (isNewEl) {
// this.resizeObserver = this.handleResize(el, this.resizeObserver);
// }
// };
// childrenElementWithRef(children) {
// const child = React.Children.only(children);
// return React.cloneElement(child, {
// ref: this.setRef,
// style: {
// wordWrap: 'break-word',
// ...child.props.style
// }
// });
// }
// truncateOriginalText() {
// this.endFound = false;
// this.splitDirectionSeq = [SPLIT.LEFT];
// this.wasLastCharTested = false;
// this.tryToFit(this.origText, this.splitDirectionSeq);
// }
// /**
// * Splits rootEl based on instructions and updates React's state with the returned element
// * After React rerenders the new text, we'll check if the new text fits in componentDidUpdate
// * @param {ReactElement} rootEl - the original children element
// * @param {Array} splitDirections - list of SPLIT.RIGHT/LEFT instructions
// */
// tryToFit(rootEl, splitDirections) {
// if (!rootEl.props.children) {
// // no markup in container
// return;
// }
// const newRootEl = this.split(rootEl, splitDirections, /* isRootEl */ true);
// let ellipsis =
// typeof this.props.ellipsis === 'function'
// ? this.props.ellipsis(newRootEl)
// : this.props.ellipsis;
// ellipsis =
// typeof ellipsis === 'object'
// ? React.cloneElement(ellipsis, { key: 'ellipsis' })
// : ellipsis;
// const newChildren = newRootEl.props.children;
// const newChildrenWithEllipsis = [].concat(newChildren, ellipsis);
// // edge case tradeoff EC#1 - on initial render it doesn't fit in the requested number of lines (1) so it starts truncating
// // - because of truncating and the ellipsis position, div#lvl2 will have display set to 'inline-block',
// // causing the whole body to fit in 1 line again
// // - if that happens, ellipsis is not needed anymore as the whole body is rendered
// // - NOTE this could be fixed by checking for this exact case and handling it separately so it renders <div>foo {ellipsis}</div>
// //
// // Example:
// // <TruncateMarkup lines={1}>
// // <div>
// // foo
// // <div id="lvl2">bar</div>
// // </div>
// // </TruncateMarkup>
// const shouldRenderEllipsis =
// toString(newChildren) !== this.toStringMemo(this.props.children);
// this.setState({
// text: {
// ...newRootEl,
// props: {
// ...newRootEl.props,
// children: shouldRenderEllipsis ? newChildrenWithEllipsis : newChildren
// }
// }
// });
// }
// /**
// * Splits JSX node based on its type
// * @param {null|string|Array|Object} node - JSX node
// * @param {Array} splitDirections - list of SPLIT.RIGHT/LEFT instructions
// * @return {null|string|Array|Object} - split JSX node
// */
// split(node, splitDirections, isRoot = false, level = 1) {
// if (!node || isAtomComponent(node)) {
// this.endFound = true;
// return node;
// } else if (typeof node === 'string') {
// return this.splitString(node, splitDirections, level);
// } else if (Array.isArray(node)) {
// return this.splitArray(node, splitDirections, level);
// }
// const newChildren = this.split(
// node.props.children,
// splitDirections,
// /* isRoot */ false,
// level + 1
// );
// return cloneWithChildren(node, newChildren, isRoot, level);
// }
// splitString(string, splitDirections = [], level) {
// if (!splitDirections.length) {
// return string;
// }
// if (splitDirections.length && this.policy.isAtomic(string)) {
// // allow for an extra render test with the current character included
// // in most cases this variation was already tested, but some edge cases require this check
// // NOTE could be removed once EC#1 is taken care of
// if (!this.wasLastCharTested) {
// this.wasLastCharTested = true;
// } else {
// // we are trying to split further but we have nowhere to go now
// // that means we've already found the max subtree that fits the container
// this.endFound = true;
// }
// return string;
// }
// if (this.policy.tokenizeString) {
// const wordsArray = this.splitArray(
// this.policy.tokenizeString(string),
// splitDirections,
// level
// );
// // in order to preserve the input structure
// return wordsArray.join('');
// }
// const [splitDirection, ...restSplitDirections] = splitDirections;
// const pivotIndex = Math.ceil(string.length / 2);
// const beforeString = string.substring(0, pivotIndex);
// if (splitDirection === SPLIT.LEFT) {
// return this.splitString(beforeString, restSplitDirections, level);
// }
// const afterString = string.substring(pivotIndex);
// return beforeString + this.splitString(afterString, restSplitDirections, level);
// }
// splitArray(array, splitDirections = [], level) {
// if (!splitDirections.length) {
// return array;
// }
// if (array.length === 1) {
// return [this.split(array[0], splitDirections, /* isRoot */ false, level)];
// }
// const [splitDirection, ...restSplitDirections] = splitDirections;
// const pivotIndex = Math.ceil(array.length / 2);
// const beforeArray = array.slice(0, pivotIndex);
// if (splitDirection === SPLIT.LEFT) {
// return this.splitArray(beforeArray, restSplitDirections, level);
// }
// const afterArray = array.slice(pivotIndex);
// return beforeArray.concat(this.splitArray(afterArray, restSplitDirections, level));
// }
// fits() {
// const { lines: maxLines } = this.props;
// const { height } = this.el.getBoundingClientRect();
// const computedLines = Math.round(height / parseFloat(this.lineHeight));
// return maxLines >= computedLines;
// }
// render() {
// return this.state.text;
// }
// }
import getLineHeight from 'line-height';
import memoizeOne from 'memoize-one';
import PropTypes from 'prop-types';
import React from 'react';
import ResizeObserver from 'resize-observer-polyfill';
import { Atom, ATOM_STRING_ID, isAtomComponent } from './atom';
const TOKENIZE_POLICY = {
characters: {
tokenizeString: null,
isAtomic: (str: string) => str.length <= 1
},
words: {
tokenizeString: (str: string) => str.match(/(\s*\S[\S\xA0]*)/g),
isAtomic: (str: string) => /^\s*[\S\xA0]*\s*$/.test(str)
}
};
const SPLIT = {
LEFT: true,
RIGHT: false
};
const toString = (node, string = '') => {
if (!node) {
return string;
} else if (typeof node === 'string') {
return string + node;
} else if (isAtomComponent(node)) {
return string + ATOM_STRING_ID;
}
const children = Array.isArray(node) ? node : node.props.children || '';
return string + React.Children.map(children, (child) => toString(child)).join('');
};
const cloneWithChildren = (node, children, isRootEl, level) => {
const getDisplayStyle = () => {
if (isRootEl) {
return {
// root element cannot be an inline element because of the line calculation
display: (node.props.style || {}).display || 'block'
};
} else if (level === 2) {
return {
// level 2 elements (direct children of the root element) need to be inline because of the ellipsis.
// if level 2 element was a block element, ellipsis would get rendered on a new line, breaking the max number of lines
display: (node.props.style || {}).display || 'inline-block'
};
} else return {};
};
return {
...node,
props: {
...node.props,
style: {
...node.props.style,
...getDisplayStyle()
},
children
}
};
};
const validateTree = (node) => {
if (node == null || ['string', 'number'].includes(typeof node) || isAtomComponent(node)) {
return true;
} else if (typeof node.type === 'function') {
if (process.env.NODE_ENV !== 'production') {
/* eslint-disable no-console */
console.error(
`ReactTruncateMarkup tried to render <${node.type.name} />, but truncating React components is not supported, the full content is rendered instead. Only DOM elements are supported. Alternatively, you can take advantage of the <TruncateMarkup.Atom /> component (see more in the docs https://github.com/patrik-piskay/react-truncate-markup/blob/master/README.md#truncatemarkupatom-).`
);
/* eslint-enable */
}
return false;
}
if (node.props && node.props.children) {
return React.Children.toArray(node.props.children).reduce(
(isValid, child) => isValid && validateTree(child),
true
);
}
return true;
};
export interface TruncateMarkupProps {
lines: number;
ellipsis?: JSX.Element | ((element: JSX.Element) => JSX.Element);
children: JSX.Element;
lineHeight?: number | string;
tokenize?: string;
onTruncate?: (wasTruncated: boolean) => any;
}
export default class TruncateMarkup extends React.Component<TruncateMarkupProps> {
static Atom = Atom;
static propTypes = {
children: PropTypes.element.isRequired,
lines: PropTypes.number,
ellipsis: PropTypes.oneOfType([PropTypes.element, PropTypes.string, PropTypes.func]),
lineHeight: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
onTruncate: PropTypes.func,
// eslint-disable-next-line
onAfterTruncate: (props, propName, componentName) => {
if (props[propName]) {
return new Error(
`${componentName}: Setting \`onAfterTruncate\` prop is deprecated, use \`onTruncate\` instead.`
);
}
},
tokenize: (props, propName, componentName) => {
const tokenizeValue = props[propName];
if (typeof tokenizeValue !== 'undefined') {
if (!TOKENIZE_POLICY[tokenizeValue]) {
/* eslint-disable no-console */
return new Error(
`${componentName}: Unknown option for prop 'tokenize': '${tokenizeValue}'. Option 'characters' will be used instead.`
);
/* eslint-enable */
}
}
}
};
static defaultProps = {
lines: 1,
ellipsis: '...',
lineHeight: '',
onTruncate: () => {},
tokenize: 'characters'
};
constructor(props) {
super(props);
this.state = {
text: this.childrenWithRefMemo(this.props.children)
};
}
lineHeight = null;
splitDirectionSeq = [];
shouldTruncate = true;
wasLastCharTested = false;
endFound = false;
latestThatFits = null;
onTruncateCalled = false;
toStringMemo = memoizeOne(toString);
childrenWithRefMemo = memoizeOne(this.childrenElementWithRef);
validateTreeMemo = memoizeOne(validateTree);
get isValid() {
return this.validateTreeMemo(this.props.children);
}
get origText() {
return this.childrenWithRefMemo(this.props.children);
}
get policy() {
return TOKENIZE_POLICY[this.props.tokenize] || TOKENIZE_POLICY.characters;
}
componentDidMount() {
if (!this.isValid) {
return;
}
// get the computed line-height of the parent element
// it'll be used for determining whether the text fits the container or not
this.lineHeight = this.props.lineHeight || getLineHeight(this.el);
this.truncate();
}
UNSAFE_componentWillReceiveProps(nextProps) {
this.shouldTruncate = false;
this.latestThatFits = null;
this.setState(
{
text: this.childrenWithRefMemo(nextProps.children)
},
() => {
if (!this.isValid) {
return;
}
this.lineHeight = nextProps.lineHeight || getLineHeight(this.el);
this.shouldTruncate = true;
this.truncate();
}
);
}
componentDidUpdate() {
if (this.shouldTruncate === false || this.isValid === false) {
return;
}
if (this.endFound) {
// we've found the end where we cannot split the text further
// that means we've already found the max subtree that fits the container
// so we are rendering that
if (this.latestThatFits !== null && this.state.text !== this.latestThatFits) {
/* eslint-disable react/no-did-update-set-state */
this.setState({
text: this.latestThatFits
});
return;
/* eslint-enable */
}
this.onTruncate(/* wasTruncated */ true);
return;
}
if (this.splitDirectionSeq.length) {
if (this.fits()) {
this.latestThatFits = this.state.text;
// we've found a subtree that fits the container
// but we need to check if we didn't cut too much of it off
// so we are changing the last splitting decision from splitting and going left
// to splitting and going right
this.splitDirectionSeq.splice(
this.splitDirectionSeq.length - 1,
1,
SPLIT.RIGHT,
SPLIT.LEFT
);
} else {
this.splitDirectionSeq.push(SPLIT.LEFT);
}
this.tryToFit(this.origText, this.splitDirectionSeq);
}
}
componentWillUnmount() {
this.lineHeight = null;
this.latestThatFits = null;
this.splitDirectionSeq = [];
}
onTruncate = (wasTruncated) => {
if (!this.onTruncateCalled) {
this.onTruncateCalled = true;
this.props.onTruncate(wasTruncated);
}
};
handleResize = (el, prevResizeObserver) => {
// clean up previous observer
if (prevResizeObserver) {
prevResizeObserver.disconnect();
}
// unmounting or just unsetting the element to be replaced with a new one later
if (!el) return null;
/* Wrapper element resize handing */
let initialRender = true;
const resizeCallback = () => {
if (initialRender) {
// ResizeObserer cb is called on initial render too so we are skipping here
initialRender = false;
} else {
// wrapper element has been resized, recalculating with the original text
this.shouldTruncate = false;
this.latestThatFits = null;
this.setState(
{
text: this.origText
},
() => {
this.shouldTruncate = true;
this.onTruncateCalled = false;
this.truncate();
}
);
}
};
const resizeObserver = prevResizeObserver || new ResizeObserver(resizeCallback);
resizeObserver.observe(el);
return resizeObserver;
};
truncate() {
if (this.fits()) {
// the whole text fits on the first try, no need to do anything else
this.shouldTruncate = false;
this.onTruncate(/* wasTruncated */ false);
return;
}
this.truncateOriginalText();
}
setRef = (el) => {
const isNewEl = this.el !== el;
this.el = el;
// whenever we obtain a new element, attach resize handler
if (isNewEl) {
this.resizeObserver = this.handleResize(el, this.resizeObserver);
}
};
childrenElementWithRef(children) {
const child = React.Children.only(children);
return React.cloneElement(child, {
ref: this.setRef,
style: {
wordWrap: 'break-word',
...child.props.style
}
});
}
truncateOriginalText() {
this.endFound = false;
this.splitDirectionSeq = [SPLIT.LEFT];
this.wasLastCharTested = false;
this.tryToFit(this.origText, this.splitDirectionSeq);
}
/**
* Splits rootEl based on instructions and updates React's state with the returned element
* After React rerenders the new text, we'll check if the new text fits in componentDidUpdate
* @param {ReactElement} rootEl - the original children element
* @param {Array} splitDirections - list of SPLIT.RIGHT/LEFT instructions
*/
tryToFit(rootEl, splitDirections) {
if (!rootEl.props.children) {
// no markup in container
return;
}
const newRootEl = this.split(rootEl, splitDirections, /* isRootEl */ true);
let ellipsis =
typeof this.props.ellipsis === 'function'
? this.props.ellipsis(newRootEl)
: this.props.ellipsis;
ellipsis =
typeof ellipsis === 'object'
? React.cloneElement(ellipsis, { key: 'ellipsis' })
: ellipsis;
const newChildren = newRootEl.props.children;
const newChildrenWithEllipsis = [].concat(newChildren, ellipsis);
// edge case tradeoff EC#1 - on initial render it doesn't fit in the requested number of lines (1) so it starts truncating
// - because of truncating and the ellipsis position, div#lvl2 will have display set to 'inline-block',
// causing the whole body to fit in 1 line again
// - if that happens, ellipsis is not needed anymore as the whole body is rendered
// - NOTE this could be fixed by checking for this exact case and handling it separately so it renders <div>foo {ellipsis}</div>
//
// Example:
// <TruncateMarkup lines={1}>
// <div>
// foo
// <div id="lvl2">bar</div>
// </div>
// </TruncateMarkup>
const shouldRenderEllipsis =
toString(newChildren) !== this.toStringMemo(this.props.children);
this.setState({
text: {
...newRootEl,
props: {
...newRootEl.props,
children: shouldRenderEllipsis ? newChildrenWithEllipsis : newChildren
}
}
});
}
/**
* Splits JSX node based on its type
* @param {null|string|Array|Object} node - JSX node
* @param {Array} splitDirections - list of SPLIT.RIGHT/LEFT instructions
* @return {null|string|Array|Object} - split JSX node
*/
split(node, splitDirections, isRoot = false, level = 1) {
if (!node || isAtomComponent(node)) {
this.endFound = true;
return node;
} else if (typeof node === 'string') {
return this.splitString(node, splitDirections, level);
} else if (Array.isArray(node)) {
return this.splitArray(node, splitDirections, level);
}
const newChildren = this.split(
node.props.children,
splitDirections,
/* isRoot */ false,
level + 1
);
return cloneWithChildren(node, newChildren, isRoot, level);
}
splitString(string, splitDirections = [], level) {
if (!splitDirections.length) {
return string;
}
if (splitDirections.length && this.policy.isAtomic(string)) {
// allow for an extra render test with the current character included
// in most cases this variation was already tested, but some edge cases require this check
// NOTE could be removed once EC#1 is taken care of
if (!this.wasLastCharTested) {
this.wasLastCharTested = true;
} else {
// we are trying to split further but we have nowhere to go now
// that means we've already found the max subtree that fits the container
this.endFound = true;
}
return string;
}
if (this.policy.tokenizeString) {
const wordsArray = this.splitArray(
this.policy.tokenizeString(string),
splitDirections,
level
);
// in order to preserve the input structure
return wordsArray.join('');
}
const [splitDirection, ...restSplitDirections] = splitDirections;
const pivotIndex = Math.ceil(string.length / 2);
const beforeString = string.substring(0, pivotIndex);
if (splitDirection === SPLIT.LEFT) {
return this.splitString(beforeString, restSplitDirections, level);
}
const afterString = string.substring(pivotIndex);
return beforeString + this.splitString(afterString, restSplitDirections, level);
}
splitArray(array, splitDirections = [], level) {
if (!splitDirections.length) {
return array;
}
if (array.length === 1) {
return [this.split(array[0], splitDirections, /* isRoot */ false, level)];
}
const [splitDirection, ...restSplitDirections] = splitDirections;
const pivotIndex = Math.ceil(array.length / 2);
const beforeArray = array.slice(0, pivotIndex);
if (splitDirection === SPLIT.LEFT) {
return this.splitArray(beforeArray, restSplitDirections, level);
}
const afterArray = array.slice(pivotIndex);
return beforeArray.concat(this.splitArray(afterArray, restSplitDirections, level));
}
fits() {
const { lines: maxLines } = this.props;
const { height } = this.el.getBoundingClientRect();
const computedLines = Math.round(height / parseFloat(this.lineHeight));
return maxLines >= computedLines;
}
render() {
return this.state.text;
}
}

View file

@ -67,7 +67,7 @@ export const GridItem = (props: Props) => {
class="h-full w-full"
data-selectable=""
data-selectable-index={props.index}
data-selectable-id={itemId}
data-selectable-id={itemId()}
onContextMenu={(e) => {
if (explorerView.selectable && !explorer.selectedItems().has(props.item)) {
explorer.resetSelectedItems([props.item]);

View file

@ -16,24 +16,35 @@ export function VirtualGrid({ grid, children, ...props }: GridProps) {
const [offset, setOffset] = Solid.createSignal(0);
const useVisibilityObserver = createVisibilityObserver();
// const useVisibilityObserver = createVisibilityObserver();
let loadMoreRef: HTMLDivElement | undefined;
const inView = useVisibilityObserver(() => loadMoreRef);
// const inView = useVisibilityObserver(() => loadMoreRef);
const rowVirtualizer = createVirtualizer({
...grid().virtualizer.rowVirtualizer(),
get count() {
return grid().virtualizer.rowVirtualizer.count;
},
getScrollElement: () => grid().virtualizer.rowVirtualizer.getScrollElement(),
estimateSize: (index) => grid().virtualizer.rowVirtualizer.estimateSize(index),
get scrollMargin() {
return offset();
}
});
const columnVirtualizer = createVirtualizer(grid().virtualizer.columnVirtualizer());
const columnVirtualizer = createVirtualizer({
horizontal: true,
get count() {
return grid().virtualizer.columnVirtualizer.count;
},
getScrollElement: () => grid().virtualizer.columnVirtualizer.getScrollElement(),
estimateSize: (index) => grid().virtualizer.columnVirtualizer.estimateSize(index)
});
const width = columnVirtualizer.getTotalSize();
const height = rowVirtualizer.getTotalSize();
const width = Solid.createMemo(() => columnVirtualizer.getTotalSize());
const height = Solid.createMemo(() => rowVirtualizer.getTotalSize());
const internalWidth = () => width - (grid().padding.left + grid().padding.right);
const internalHeight = () => height - (grid().padding.top + grid().padding.bottom);
const internalWidth = () => width() - (grid().padding.left + grid().padding.right);
const internalHeight = () => height() - (grid().padding.top + grid().padding.bottom);
const loadMoreTriggerHeight = Solid.createMemo(() => {
if (grid().horizontal || !grid().onLoadMore || !grid().rowCount || !grid().totalRowCount)
@ -54,9 +65,9 @@ export function VirtualGrid({ grid, children, ...props }: GridProps) {
loadMoreHeight = Math.max(0, rowVirtualizer.scrollElement.clientHeight - offset);
}
const triggerHeight = height - lastRowTop + loadMoreHeight;
const triggerHeight = height() - lastRowTop + loadMoreHeight;
return Math.min(height, triggerHeight);
return Math.min(height(), triggerHeight);
});
const loadMoreTriggerWidth = Solid.createMemo(() => {
@ -76,9 +87,9 @@ export function VirtualGrid({ grid, children, ...props }: GridProps) {
const loadMoreWidth =
grid().loadMoreSize ?? columnVirtualizer.scrollElement?.clientWidth ?? 0;
const triggerWidth = width - lastColumnLeft + loadMoreWidth;
const triggerWidth = width() - lastColumnLeft + loadMoreWidth;
return Math.min(width, triggerWidth);
return Math.min(width(), triggerWidth);
});
Solid.createEffect(
@ -95,24 +106,32 @@ export function VirtualGrid({ grid, children, ...props }: GridProps) {
)
);
Solid.createEffect(() => {
if (inView()) grid().onLoadMore?.();
});
// Solid.createEffect(() => {
// if (inView()) grid().onLoadMore?.();
// });
Solid.createEffect(() => {
const element = grid().scrollRef();
if (!element) return;
// Solid.createEffect(() => {
// const element = grid().scrollRef();
// if (!element) return;
const observer = new MutationObserver(() => setOffset(ref?.offsetTop ?? 0));
// const observer = new MutationObserver(() => setOffset(ref?.offsetTop ?? 0));
observer.observe(element, {
childList: true
});
// observer.observe(element, {
// childList: true
// });
return () => observer.disconnect();
});
// return () => observer.disconnect();
// });
Solid.createEffect(() => setOffset(ref?.offsetTop ?? 0), []);
// Solid.createEffect(() => setOffset(ref?.offsetTop ?? 0), []);
Solid.createEffect(() =>
console.log({
row: rowVirtualizer.getVirtualItems(),
column: columnVirtualizer.getVirtualItems(),
virtualizer: grid().virtualizer
})
);
return (
<div
@ -121,8 +140,8 @@ export function VirtualGrid({ grid, children, ...props }: GridProps) {
style={{
...props.style,
position: 'relative',
width: width.toString(),
height: height.toString()
width: `${width()}px`,
height: `${height()}px`
}}
>
<Solid.Show when={internalWidth() > 0 || internalHeight() > 0}>
@ -142,47 +161,61 @@ export function VirtualGrid({ grid, children, ...props }: GridProps) {
{(virtualRow) => (
<Solid.For each={columnVirtualizer.getVirtualItems()}>
{(virtualColumn) => {
let index = grid().horizontal
? virtualColumn.index * grid().rowCount + virtualRow.index
: virtualRow.index * grid().columnCount + virtualColumn.index;
const index = Solid.createMemo(() => {
let index = grid().horizontal
? virtualColumn.index * grid().rowCount + virtualRow.index
: virtualRow.index * grid().columnCount +
virtualColumn.index;
if (grid().invert) index = grid().count - 1 - index;
if (grid().invert) index = grid().count - 1 - index;
if (index >= grid().count || index < 0) return null;
if (index >= grid().count || index < 0) return null;
return { value: index };
});
return (
<div
data-index={index}
style={{
'position': 'absolute',
'top': 0,
'left': 0,
'width': `${virtualColumn.size}px`,
'height': `${virtualRow.size}px`,
'transform': `translateX(${
virtualColumn.start
}px) translateY(${
virtualRow.start -
rowVirtualizer.options.scrollMargin
}px)`,
'padding-left':
virtualColumn.index !== 0
? grid().gap.x.toString()
: 0,
'padding-top':
virtualRow.index !== 0 ? grid().gap.y.toString() : 0
}}
>
<div
style={{
margin: 'auto',
width: grid().itemSize.width?.toString() ?? '100%',
height: grid().itemSize.height?.toString() ?? '100%'
}}
>
{children(index)}
</div>
</div>
<Solid.Show when={index()}>
{(index) => (
<div
data-index={index().value}
style={{
'position': 'absolute',
'top': 0,
'left': 0,
'width': `${virtualColumn.size}px`,
'height': `${virtualRow.size}px`,
'transform': `translateX(${
virtualColumn.start
}px) translateY(${
virtualRow.start -
rowVirtualizer.options.scrollMargin
}px)`,
'padding-left':
virtualColumn.index !== 0
? grid().gap.x.toString()
: 0,
'padding-top':
virtualRow.index !== 0
? grid().gap.y.toString()
: 0
}}
>
<div
style={{
margin: 'auto',
width:
`${grid().itemSize.width?.toString()}px` ??
'100%',
height:
`${grid().itemSize.height?.toString()}px` ??
'100%'
}}
>
{children(index().value)}
</div>
</div>
)}
</Solid.Show>
);
}}
</Solid.For>

View file

@ -1,7 +1,8 @@
import { createElementSize, createResizeObserver } from '@solid-primitives/resize-observer';
import { type createVirtualizer } from '@tanstack/solid-virtual';
import * as Core from '@virtual-grid/core';
import { createEffect, createMemo, createSignal, type Accessor } from 'solid-js';
import { createEffect, createMemo, createSignal, onMount, type Accessor } from 'solid-js';
import { createStore } from 'solid-js/store';
type VirtualizerOptions = Parameters<typeof createVirtualizer>[0];
@ -37,59 +38,83 @@ export type CreateGridProps<IdT extends Core.GridItemId, DataT extends Core.Grid
overscan?: number;
};
export function createGrid<IdT extends Core.GridItemId, DataT extends Core.GridItemData>({
scrollRef,
overscan,
...props
}: CreateGridProps<IdT, DataT>) {
export function createGrid<IdT extends Core.GridItemId, DataT extends Core.GridItemData>(
props: Accessor<CreateGridProps<IdT, DataT>>
) {
const [width, setWidth] = createSignal(0);
let staticWidth: number | null = null;
const grid = createMemo(() => Core.grid({ width: width(), ...props }));
const grid = createMemo(() => Core.grid({ width: width(), ...props() }));
const rowVirtualizer = createMemo<VirtualizerOptions>(() => ({
...props.rowVirtualizer,
const [rowVirtualizer, setRowVirtualizer] = createStore<VirtualizerOptions>({
...props().rowVirtualizer,
count: grid().totalRowCount,
getScrollElement: scrollRef,
getScrollElement: props().scrollRef,
estimateSize: grid().getItemHeight,
paddingStart: grid().padding.top,
paddingEnd: grid().padding.bottom,
overscan: overscan ?? props.rowVirtualizer?.overscan
}));
overscan: props().overscan ?? props().rowVirtualizer?.overscan
});
const columnVirtualizer = createMemo<VirtualizerOptions>(() => ({
...props.columnVirtualizer,
createEffect(() => {
setRowVirtualizer({
...props().rowVirtualizer,
count: grid().totalRowCount,
getScrollElement: props().scrollRef,
estimateSize: grid().getItemHeight,
paddingStart: grid().padding.top,
paddingEnd: grid().padding.bottom,
overscan: props().overscan ?? props().rowVirtualizer?.overscan
});
});
const [columnVirtualizer, setColumnVirtualizer] = createStore<VirtualizerOptions>({
...props().columnVirtualizer,
horizontal: true,
count: grid().totalColumnCount,
getScrollElement: scrollRef,
getScrollElement: props().scrollRef,
estimateSize: grid().getItemWidth,
paddingStart: grid().padding.left,
paddingEnd: grid().padding.right,
overscan: overscan ?? props.columnVirtualizer?.overscan
}));
overscan: props().overscan ?? props().columnVirtualizer?.overscan
});
createEffect(() => {
setColumnVirtualizer({
...props().columnVirtualizer,
horizontal: true,
count: grid().totalColumnCount,
getScrollElement: props().scrollRef,
estimateSize: grid().getItemWidth,
paddingStart: grid().padding.left,
paddingEnd: grid().padding.right,
overscan: props().overscan ?? props().columnVirtualizer?.overscan
});
});
const isStatic = createMemo(
() =>
props.width !== undefined ||
props.horizontal ||
props.columns === 0 ||
(props.columns === 'auto'
? !props.size || (typeof props.size === 'object' && !props.size.width)
: (props.columns === undefined || props.columns) &&
((typeof props.size === 'object' && props.size.width) ||
typeof props.size === 'number'))
props().width !== undefined ||
props().horizontal ||
props().columns === 0 ||
(props().columns === 'auto'
? !props().size || (typeof props().size === 'object' && !props().size.width)
: (props().columns === undefined || props().columns) &&
((typeof props().size === 'object' && props().size.width) ||
typeof props().size === 'number'))
);
createElementSize;
createResizeObserver(scrollRef, ({ width }) => {
if (width === undefined || isStatic()) {
if (width !== undefined) staticWidth = width;
return;
createResizeObserver(
() => props().scrollRef(),
({ width }) => {
if (width === undefined || isStatic()) {
if (width !== undefined) staticWidth = width;
return;
}
setWidth(width);
}
setWidth(width);
});
);
createEffect(() => {
if (staticWidth === null || width() === staticWidth || isStatic()) return;
@ -99,12 +124,12 @@ export function createGrid<IdT extends Core.GridItemId, DataT extends Core.GridI
return createMemo(() => ({
...grid(),
scrollRef: scrollRef,
onLoadMore: props.onLoadMore,
loadMoreSize: props.loadMoreSize,
scrollRef: props().scrollRef,
onLoadMore: props().onLoadMore,
loadMoreSize: props().loadMoreSize,
virtualizer: {
rowVirtualizer: rowVirtualizer,
columnVirtualizer: columnVirtualizer
rowVirtualizer,
columnVirtualizer
}
}));
}

View file

@ -1,5 +1,5 @@
// import { Grid, useGrid } from '@virtual-grid/react';
import { createEffect, createSignal, type JSX } from 'solid-js';
import { createEffect, createMemo, createSignal, Show, type JSX } from 'solid-js';
import { type ExplorerItem } from '@sd/client';
import { useExplorerContext } from '../../Context';
@ -18,16 +18,15 @@ export type RenderItem = (item: {
}) => JSX.Element;
const CHROME_REGEX = /Chrome/;
const isChrome = CHROME_REGEX.test(navigator.userAgent);
export default ({ children }: { children: RenderItem }) => {
export default (props: { children: RenderItem }) => {
// const os = useOperatingSystem();
// const realOS = useOperatingSystem(true);
const isChrome = CHROME_REGEX.test(navigator.userAgent);
const explorer = useExplorerContext();
const explorerView = useExplorerViewContext();
const explorerSettings = explorer.useSettingsSnapshot();
// const explorerSettings = explorer.useSettingsSnapshot();
// const quickPreviewStore = useQuickPreviewStore();
// const selecto = useRef<Selecto>(null);
@ -43,34 +42,34 @@ export default ({ children }: { children: RenderItem }) => {
const [dragFromThumbnail, setDragFromThumbnail] = createSignal(false);
const itemDetailsHeight = 44 + (explorerSettings.showBytesInGridView ? 20 : 0);
const itemHeight = explorerSettings.gridItemSize + itemDetailsHeight;
const padding = explorerSettings.layoutMode === 'grid' ? 12 : 0;
const itemDetailsHeight = 44 + (false ? 20 : 0);
// const itemHeight = explorerSettings.gridItemSize + itemDetailsHeight;
const itemHeight = 100 + itemDetailsHeight;
// const padding = explorerSettings.layoutMode === 'grid' ? 12 : 0;
const grid = createGrid({
const grid = createGrid(() => ({
scrollRef: explorer.scrollRef,
count: explorer.items?.length ?? 0,
count: explorer.items()?.length ?? 0,
totalCount: explorer.count,
...(explorerSettings.layoutMode === 'grid'
? {
columns: 'auto',
size: { width: explorerSettings.gridItemSize, height: itemHeight }
}
: { columns: explorerSettings.mediaColumns }),
rowVirtualizer: { overscan: explorer.overscan ?? 5 },
// ...(explorerSettings.layoutMode === 'grid'
// ?
columns: 'auto',
size: { width: 100, height: itemHeight },
// : { columns: explorerSettings.mediaColumns }),
rowVirtualizer: { overscan: explorer.overscan ?? 10 },
onLoadMore: explorer.loadMore,
getItemId: (index: number) => {
getItemId: (index) => {
const item = explorer.items()?.[index];
return item ? uniqueId(item) : undefined;
},
getItemData: (index: number) => explorer.items()?.[index],
padding: {
bottom: explorerView.bottom ? padding + explorerView.bottom : undefined,
x: padding,
y: padding
},
gap: explorerSettings.layoutMode === 'grid' ? explorerSettings.gridGap : 1
});
getItemData: (index) => explorer.items()?.[index]
// padding: {
// bottom: explorerView.bottom ? padding + explorerView.bottom : undefined,
// x: padding,
// y: padding
// },
// gap: explorerSettings.layoutMode === 'grid' ? explorerSettings.gridGap : 1
}));
const getElementById = (id: string) => {
if (!explorer.parent) return;
@ -115,57 +114,6 @@ export default ({ children }: { children: RenderItem }) => {
return activeItem;
}
// function handleDragEnd() {
// getExplorerStore().isDragSelecting = false;
// selectoFirstColumn.current = undefined;
// selectoLastColumn.current = undefined;
// setDragFromThumbnail(false);
// const allSelected = selecto.current?.getSelectedTargets() ?? [];
// activeItem.current = getActiveItem(allSelected);
// }
// useEffect(
// () => {
// const element = explorer.scrollRef.current;
// if (!element) return;
// const handleScroll = () => {
// selecto.current?.checkScroll();
// selecto.current?.findSelectableTargets();
// };
// element.addEventListener('scroll', handleScroll);
// return () => element.removeEventListener('scroll', handleScroll);
// },
// // explorer.scrollRef is a stable reference so this only actually runs once
// [explorer.scrollRef]
// );
// useEffect(() => {
// if (!selecto.current) return;
// const set = new Set(explorer.selectedItemHashes.value);
// if (set.size === 0) return;
// const items = [...document.querySelectorAll('[data-selectable]')].filter((item) => {
// const id = getElementId(item);
// if (id === null) return;
// const selected = set.has(id);
// if (selected) set.delete(id);
// return selected;
// });
// selectoUnselected.current = set;
// selecto.current.setSelectedTargets(items as HTMLElement[]);
// activeItem.current = getActiveItem(items);
// // eslint-disable-next-line react-hooks/exhaustive-deps
// }, [grid.columnCount, explorer.items]);
createEffect(() => {
if (explorer.selectedItems().size !== 0) return;
@ -179,165 +127,6 @@ export default ({ children }: { children: RenderItem }) => {
// selecto.current?.setSelectedTargets([]);
// });
const keyboardHandler = (e: KeyboardEvent, newIndex: number) => {
if (!explorerView.selectable) return;
if (explorer.selectedItems().size > 0) {
e.preventDefault();
e.stopPropagation();
}
const newSelectedItem = grid().getItem(newIndex);
if (!newSelectedItem?.data) return;
if (!explorer.allowMultiSelect) explorer.resetSelectedItems([newSelectedItem.data]);
else {
const selectedItemElement = getElementById(uniqueId(newSelectedItem.data));
if (!selectedItemElement) return;
// if (e.shiftKey && !getQuickPreviewStore().open) {
// if (!explorer.selectedItems.has(newSelectedItem.data)) {
// explorer.addSelectedItem(newSelectedItem.data);
// // selecto.current?.setSelectedTargets([
// // ...(selecto.current?.getSelectedTargets() || []),
// // selectedItemElement as HTMLElement
// // ]);
// }
// } else {
explorer.resetSelectedItems([newSelectedItem.data]);
// selecto.current?.setSelectedTargets([selectedItemElement as HTMLElement]);
// if (selectoUnselected.current.size > 0) selectoUnselected.current = new Set();
// }
}
activeItem = newSelectedItem.data;
const scrollRef = explorer.scrollRef(),
viewRef = explorerView.ref();
if (!scrollRef || !viewRef) return;
const { top: viewTop } = viewRef.getBoundingClientRect();
const itemTop = newSelectedItem.rect.top + viewTop;
const itemBottom = newSelectedItem.rect.bottom + viewTop;
const { height: scrollHeight } = scrollRef.getBoundingClientRect();
const scrollTop =
(explorerView.top ?? parseInt(getComputedStyle(scrollRef).paddingTop)) + 1;
const scrollBottom = scrollHeight - 2; // (os !== 'windows' && os !== 'browser' ? 2 : 1);
if (itemTop < scrollTop) {
scrollRef.scrollBy({
top:
itemTop -
scrollTop -
(newSelectedItem.row === 0 ? grid().padding.top : grid().gap.y / 2)
});
} else if (itemBottom > scrollBottom - (explorerView.bottom ?? 0)) {
scrollRef.scrollBy({
top:
itemBottom -
scrollBottom +
(explorerView.bottom ?? 0) +
(newSelectedItem.row === grid().rowCount - 1
? grid().padding.bottom
: grid().gap.y / 2)
});
}
};
const getGridItemHandler = (key: 'ArrowUp' | 'ArrowDown' | 'ArrowLeft' | 'ArrowRight') => {
const lastItem = activeItem;
if (!lastItem) return;
const lastItemIndex = explorer.items()?.findIndex((item) => item === lastItem);
if (lastItemIndex === undefined || lastItemIndex === -1) return;
const gridItem = grid().getItem(lastItemIndex);
if (!gridItem) return;
let newIndex = gridItem.index;
switch (key) {
case 'ArrowUp':
newIndex -= grid().columnCount;
break;
case 'ArrowDown':
newIndex += grid().columnCount;
break;
case 'ArrowLeft':
newIndex -= 1;
break;
case 'ArrowRight':
newIndex += 1;
break;
}
return newIndex;
};
// useShortcut('explorerDown', (e) => {
// if (!explorerView.selectable) return;
// if (explorer.selectedItems.size === 0) {
// const item = grid.getItem(0);
// if (!item?.data) return;
// const selectedItemElement = getElementById(uniqueId(item.data));
// if (!selectedItemElement) return;
// explorer.resetSelectedItems([item.data]);
// selecto.current?.setSelectedTargets([selectedItemElement as HTMLElement]);
// activeItem.current = item.data;
// return;
// }
// const newIndex = getGridItemHandler('ArrowDown');
// if (newIndex === undefined) return;
// keyboardHandler(e, newIndex);
// });
// useShortcut('explorerUp', (e) => {
// if (!explorerView.selectable) return;
// const newIndex = getGridItemHandler('ArrowUp');
// if (newIndex === undefined) return;
// keyboardHandler(e, newIndex);
// });
// useShortcut('explorerLeft', (e) => {
// if (!explorerView.selectable) return;
// const newIndex = getGridItemHandler('ArrowLeft');
// if (newIndex === undefined) return;
// keyboardHandler(e, newIndex);
// });
// useShortcut('explorerRight', (e) => {
// if (!explorerView.selectable) return;
// const newIndex = getGridItemHandler('ArrowRight');
// if (newIndex === undefined) return;
// keyboardHandler(e, newIndex);
// });
//everytime selected items change within quick preview we need to update selecto
// useEffect(() => {
// if (!selecto.current || !quickPreviewStore.open) return;
// if (explorer.selectedItems.size !== 1) return;
// const [item] = Array.from(explorer.selectedItems);
// if (!item) return;
// const itemId = uniqueId(item);
// const element = getElementById(itemId);
// if (!element) selectoUnselected.current = new Set(itemId);
// else selecto.current.setSelectedTargets([element as HTMLElement]);
// activeItem.current = item;
// }, [explorer.items, explorer.selectedItems, quickPreviewStore.open, realOS, getElementById]);
return (
<GridContext.Provider
value={{
@ -346,278 +135,39 @@ export default ({ children }: { children: RenderItem }) => {
getElementById
}}
>
{/* {explorer.allowMultiSelect && (
<Selecto
ref={selecto}
boundContainer={
explorerView.ref.current
? {
element: explorerView.ref.current,
top: false,
bottom: false
}
: undefined
}
selectableTargets={['[data-selectable]']}
toggleContinueSelect="shift"
hitRate={0}
onDrag={(e) => {
if (!getExplorerStore().drag) return;
e.stop();
handleDragEnd();
}}
onDragStart={({ inputEvent }) => {
getExplorerStore().isDragSelecting = true;
if ((inputEvent as MouseEvent).target instanceof HTMLImageElement) {
setDragFromThumbnail(true);
}
}}
onDragEnd={handleDragEnd}
onScroll={({ direction }) => {
selecto.current?.findSelectableTargets();
explorer.scrollRef.current?.scrollBy(
(direction[0] || 0) * 10,
(direction[1] || 0) * 10
);
}}
scrollOptions={{
container: { current: explorer.scrollRef.current },
throttleTime: isChrome || dragFromThumbnail ? 30 : 10000
}}
onSelect={(e) => {
const inputEvent = e.inputEvent as MouseEvent;
if (inputEvent.type === 'mousedown') {
const el = inputEvent.shiftKey
? e.added[0] || e.removed[0]
: e.selected[0];
if (!el) return;
const item = getElementItem(el);
if (!item?.data) return;
if (!inputEvent.shiftKey) {
if (explorer.selectedItems.has(item.data)) {
selecto.current?.setSelectedTargets(e.beforeSelected);
} else {
selectoUnselected.current = new Set();
explorer.resetSelectedItems([item.data]);
}
return;
}
if (e.added[0]) explorer.addSelectedItem(item.data);
else explorer.removeSelectedItem(item.data);
} else if (inputEvent.type === 'mousemove') {
const unselectedItems: string[] = [];
e.added.forEach((el) => {
const item = getElementItem(el);
if (!item?.data) return;
explorer.addSelectedItem(item.data);
});
e.removed.forEach((el) => {
const item = getElementItem(el);
if (!item?.data || typeof item.id === 'number') return;
if (document.contains(el)) explorer.removeSelectedItem(item.data);
else unselectedItems.push(item.id);
});
const dragDirection = {
x: inputEvent.x === e.rect.left ? 'left' : 'right',
y: inputEvent.y === e.rect.bottom ? 'down' : 'up'
} as const;
const dragStart = {
x: dragDirection.x === 'right' ? e.rect.left : e.rect.right,
y: dragDirection.y === 'down' ? e.rect.top : e.rect.bottom
};
const dragEnd = { x: inputEvent.x, y: inputEvent.y };
const columns = new Set<number>();
const elements = [...e.added, ...e.removed];
const items = elements.reduce(
(items, el) => {
const item = getElementItem(el);
if (!item) return items;
columns.add(item.column);
return [...items, item];
},
[] as NonNullable<ReturnType<typeof getElementItem>>[]
);
if (columns.size > 1) {
items.sort((a, b) => a.column - b.column);
const firstItem =
dragDirection.x === 'right'
? items[0]
: items[items.length - 1];
const lastItem =
dragDirection.x === 'right'
? items[items.length - 1]
: items[0];
if (firstItem && lastItem) {
selectoFirstColumn.current = firstItem.column;
selectoLastColumn.current = lastItem.column;
}
} else if (columns.size === 1) {
const column = [...columns.values()][0]!;
items.sort((a, b) => a.row - b.row);
const itemRect = elements[0]?.getBoundingClientRect();
const inDragArea =
itemRect &&
(dragDirection.x === 'right'
? dragEnd.x >= itemRect.left
: dragEnd.x <= itemRect.right);
if (
column !== selectoLastColumn.current ||
(column === selectoLastColumn.current && !inDragArea)
) {
const firstItem =
dragDirection.y === 'down'
? items[0]
: items[items.length - 1];
if (firstItem) {
const viewRectTop =
explorerView.ref.current?.getBoundingClientRect().top ??
0;
const itemTop = firstItem.rect.top + viewRectTop;
const itemBottom = firstItem.rect.bottom + viewRectTop;
if (
dragDirection.y === 'down'
? dragStart.y < itemTop
: dragStart.y > itemBottom
) {
const dragHeight = Math.abs(
dragStart.y -
(dragDirection.y === 'down'
? itemTop
: itemBottom)
);
let itemsInDragCount =
(dragHeight - grid.gap.y) /
(grid.virtualItemSize.height + grid.gap.y);
if (itemsInDragCount > 1) {
itemsInDragCount = Math.ceil(itemsInDragCount);
} else {
itemsInDragCount = Math.round(itemsInDragCount);
}
[...Array(itemsInDragCount)].forEach((_, i) => {
const index =
dragDirection.y === 'down'
? itemsInDragCount - i
: i + 1;
const itemIndex =
firstItem.index +
(dragDirection.y === 'down' ? -index : index) *
grid.columnCount;
const item = explorer.items?.[itemIndex];
if (item) {
if (inputEvent.shiftKey) {
if (explorer.selectedItems.has(item))
explorer.removeSelectedItem(item);
else {
explorer.addSelectedItem(item);
if (inDragArea)
unselectedItems.push(
uniqueId(item)
);
}
} else if (!inDragArea)
explorer.removeSelectedItem(item);
else {
explorer.addSelectedItem(item);
if (inDragArea)
unselectedItems.push(uniqueId(item));
}
}
});
}
}
if (!inDragArea && column === selectoFirstColumn.current) {
selectoFirstColumn.current = undefined;
selectoLastColumn.current = undefined;
} else {
selectoLastColumn.current = column;
if (selectoFirstColumn.current === undefined) {
selectoFirstColumn.current = column;
}
}
}
}
if (unselectedItems.length > 0) {
selectoUnselected.current = new Set([
...selectoUnselected.current,
...unselectedItems
]);
}
}
}}
/>
)} */}
<VirtualGrid grid={grid}>
{(index) => {
const item = explorer.items()?.[index];
if (!item) return null;
const item = createMemo(() => explorer.items()?.[index]);
return (
<GridItem
index={index}
item={item}
onMouseDown={(e) => {
if (e.button !== 0 || !explorerView.selectable) return;
<Show when={item()}>
{(item) => (
<GridItem
index={index}
item={item()}
onMouseDown={(e) => {
if (e.button !== 0 || !explorerView.selectable) return;
e.stopPropagation();
e.stopPropagation();
const item = grid().getItem(index);
const item = grid().getItem(index);
if (!item?.data) return;
if (!item?.data) return;
if (!explorer.allowMultiSelect) {
explorer.resetSelectedItems([item.data]);
} else {
// selectoFirstColumn.current = item.column;
// selectoLastColumn.current = item.column;
}
if (!explorer.allowMultiSelect) {
explorer.resetSelectedItems([item.data]);
} else {
// selectoFirstColumn.current = item.column;
// selectoLastColumn.current = item.column;
}
activeItem = item.data;
}}
>
{children}
</GridItem>
activeItem = item.data;
}}
>
{props.children}
</GridItem>
)}
</Show>
);
}}
</VirtualGrid>

View file

@ -4,8 +4,8 @@ import { GridViewItem } from './Item';
export const GridView = () => {
return (
<Grid>
{({ item, selected, cut }) => (
<GridViewItem data={item} selected={selected} cut={cut} />
{(props) => (
<GridViewItem data={props.item} selected={props.selected} cut={props.cut} />
)}
</Grid>
);

View file

@ -1,7 +1,8 @@
import clsx from 'clsx';
import { JSX } from 'solid-js';
import { getExplorerItemData, useRspcLibraryContext, type ExplorerItem } from '@sd/client';
import { getExplorerItemData, type ExplorerItem } from '@sd/client';
import { useRspcLibraryContext } from '../../rspc';
import { useExplorerContext } from '../Context';
// import { toast } from '@sd/ui';
// import { useIsDark } from '~/hooks';

View file

@ -1,7 +1,6 @@
import { ReactiveSet } from '@solid-primitives/set';
import { type InfiniteQueryObserverResult } from '@tanstack/solid-query';
import { createMemo, createSignal, type Accessor, type ComponentProps } from 'solid-js';
import { useDebouncedCallback } from 'use-debounce';
import { z } from 'zod';
import type {
ExplorerItem,
@ -36,7 +35,7 @@ export type ExplorerParent =
};
export interface UseExplorerProps<TOrder extends Ordering> {
items: () => ExplorerItem[] | null;
items: Accessor<ExplorerItem[] | null>;
count?: number;
parent?: ExplorerParent;
loadMore?: () => void;
@ -52,7 +51,7 @@ export interface UseExplorerProps<TOrder extends Ordering> {
* @defaultValue `true`
*/
selectable?: boolean;
settings: ReturnType<typeof useExplorerSettings<TOrder>>;
settings: ReturnType<typeof createExplorerSettings<TOrder>>;
/**
* @defaultValue `true`
*/
@ -64,38 +63,38 @@ export interface UseExplorerProps<TOrder extends Ordering> {
* Controls top-level config and state for the explorer.
* View- and inspector-specific state is not handled here.
*/
export function createExplorer<TOrder extends Ordering>({
settings,
layouts,
...props
}: UseExplorerProps<TOrder>) {
export function createExplorer<TOrder extends Ordering>(props: UseExplorerProps<TOrder>) {
const [scrollRef, setScrollRef] = createSignal<HTMLDivElement | null>(null);
return {
return createMemo(() => ({
// Provided values
...props,
// Default values
allowMultiSelect: true,
selectable: true,
scrollRef,
setScrollRef,
count: props.items?.length,
get count() {
return props.items()?.length;
},
showPathBar: true,
layouts: {
grid: true,
list: true,
media: true,
...layouts
...props.layouts
},
...settings,
// Provided values
...props,
...props.settings,
// Selected items
...createSelectedItems(() => props.items)
};
...createSelectedItems(() => props.items() ?? [])
}));
}
export type CreateExplorer<TOrder extends Ordering> = ReturnType<typeof createExplorer<TOrder>>;
export type CreateExplorer<TOrder extends Ordering> = ReturnType<
ReturnType<typeof createExplorer<TOrder>>
>;
export function useExplorerSettings<TOrder extends Ordering>({
export function createExplorerSettings<TOrder extends Ordering>({
settings,
onSettingsChanged,
orderingKeys,
@ -110,40 +109,40 @@ export function useExplorerSettings<TOrder extends Ordering>({
}) {
// const [store] = useState(() => proxy(settings));
const updateSettings = useDebouncedCallback(
(settings: ExplorerSettings<TOrder>, location: Location) => {
onSettingsChanged?.(settings, location);
},
500
);
// const updateSettings = useDebouncedCallback(
// (settings: ExplorerSettings<TOrder>, location: Location) => {
// onSettingsChanged?.(settings, location);
// },
// 500
// );
useEffect(() => updateSettings.flush(), [location, updateSettings]);
// useEffect(() => updateSettings.flush(), [location, updateSettings]);
useEffect(() => {
if (updateSettings.isPending()) return;
Object.assign(store, settings);
}, [settings, store, updateSettings]);
// useEffect(() => {
// if (updateSettings.isPending()) return;
// Object.assign(store, settings);
// }, [settings, store, updateSettings]);
useEffect(() => {
if (!onSettingsChanged || !location) return;
const unsubscribe = subscribe(store, () => {
updateSettings(snapshot(store) as ExplorerSettings<TOrder>, location);
});
return () => unsubscribe();
}, [store, updateSettings, location, onSettingsChanged]);
// (() => {
// if (!onSettingsChanged || !location) return;
// const unsubscribe = subscribe(store, () => {
// updateSettings(snapshot(store) as ExplorerSettings<TOrder>, location);
// });
// return () => unsubscribe();
// }, [store, updateSettings, location, onSettingsChanged]);
return {
useSettingsSnapshot: () => useSnapshot(store),
settingsStore: store,
// useSettingsSnapshot: () => useSnapshot(store),
// settingsStore: store,
orderingKeys
};
}
export type UseExplorerSettings<TOrder extends Ordering> = ReturnType<
typeof useExplorerSettings<TOrder>
export type CreateExplorerSettings<TOrder extends Ordering> = ReturnType<
typeof createExplorerSettings<TOrder>
>;
function createSelectedItems(items: () => ExplorerItem[] | null) {
function createSelectedItems(items: Accessor<ExplorerItem[]>) {
// Doing pointer lookups for hashes is a bit faster than assembling a bunch of strings
// WeakMap ensures that ExplorerItems aren't held onto after they're evicted from cache
const itemHashesWeakMap = new WeakMap<ExplorerItem, string>();
@ -153,7 +152,7 @@ function createSelectedItems(items: () => ExplorerItem[] | null) {
const selectedItemHashes = new ReactiveSet<string>();
const itemsMap = createMemo(() =>
(items() ?? []).reduce((items, item) => {
items().reduce((items, item) => {
const hash = itemHashesWeakMap.get(item) ?? uniqueId(item);
itemHashesWeakMap.set(item, hash);
items.set(hash, item);

View file

@ -8,7 +8,7 @@ export function Explorer() {
return (
<div
ref={explorer.scrollRef}
ref={explorer.setScrollRef}
class="custom-scroll explorer-scroll flex flex-1 flex-col overflow-x-hidden"
style={
{

View file

@ -0,0 +1,9 @@
import { type CreateInfiniteQueryOptions } from '@tanstack/solid-query';
import { type ExplorerItem, type SearchData } from '@sd/client';
import { type Ordering } from '../store';
export type CreateExplorerInfiniteQueryArgs<TArg> = {
arg: TArg;
// explorerSettings: CreateExplorerSettings<TOrder>;
} & Pick<CreateInfiniteQueryOptions<SearchData<ExplorerItem>>, 'enabled' | 'suspense'>;

View file

@ -0,0 +1,19 @@
import { type CreateInfiniteQueryResult, type CreateQueryResult } from '@tanstack/solid-query';
import { createMemo } from 'solid-js';
export function createExplorerQuery<Q>(
query: CreateInfiniteQueryResult<Q[]>,
count: CreateQueryResult<number>
) {
const items = createMemo(() => query.data?.pages.flatMap((d) => d) ?? null);
const loadMore = () => {
if (query.hasNextPage && !query.isFetchingNextPage) {
query.fetchNextPage.call(undefined);
}
};
return { query, items, loadMore, count: count.data };
}
export type CreateExplorerQuery<Q> = ReturnType<typeof createExplorerQuery<Q>>;

View file

@ -0,0 +1,18 @@
import { type FilePathSearchArgs } from '@sd/client';
import { createLibraryQuery } from '../../rspc';
import { createExplorerQuery } from './createExplorerQuery';
import { createPathsInfiniteQuery } from './createPathsInfiniteQuery';
export function createPathsExplorerQuery(props: {
arg: FilePathSearchArgs;
// explorerSettings: CreateExplorerSettings<FilePathOrder>;
}) {
const query = createPathsInfiniteQuery();
const count = createLibraryQuery(() => ['search.pathsCount', { filters: props.arg.filters }], {
enabled: query.isSuccess
});
return createExplorerQuery(query, count);
}

View file

@ -0,0 +1,33 @@
import { createInfiniteQuery } from '@tanstack/solid-query';
import type { FilePathSearchArgs } from '@sd/client';
import { useCache as useCacheContext } from '../../cache';
import { useRspcLibraryContext } from '../../rspc';
import { useLibraryContext } from '../../useLibraryContext';
export function createPathsInfiniteQuery() {
// props: CreateExplorerInfiniteQueryArgs<FilePathSearchArgs>
const ctx = useRspcLibraryContext();
const cache = useCacheContext();
const library = useLibraryContext();
return createInfiniteQuery({
queryKey: () =>
[
'search.paths',
{ library_id: library.library.uuid, arg: {} as FilePathSearchArgs }
] as const,
queryFn: async ({ queryKey: [_, { arg }] }) => {
const result = await ctx!.client.query(['search.paths', arg]);
cache.setNodes(result.nodes);
return cache.useCache(result.items);
}
// getNextPageParam: (lastPage) => {
// if (arg.take === null || arg.take === undefined) return undefined;
// if (lastPage.items.length < arg.take) return undefined;
// else return lastPage.nodes[arg.take - 1];
// }
});
}

View file

@ -1,32 +1,143 @@
import { createInfiniteQuery } from '@tanstack/solid-query';
import { QueryClient, QueryClientProvider } from '@tanstack/solid-query';
import { createComputed, createMemo, Show } from 'solid-js';
import { type FilePathOrder, type Location } from '@sd/client';
import { cacheCtx, createCache, useCache } from './cache';
import { Explorer } from './Explorer';
import { createExplorer } from './Explorer/useExplorer';
import { createLibraryQuery, useRspcLibraryContext } from './rspc';
import { ExplorerContextProvider } from './Explorer/Context';
import { createExplorer } from './Explorer/createExplorer';
import { createPathsExplorerQuery } from './Explorer/queries/createPathsExplorerQuery';
import { filePathOrderingKeysSchema } from './Explorer/store';
import { PlatformProvider, type Platform } from './Platform';
import { createLibraryQuery, RspcProvider, useRspcLibraryContext } from './rspc';
import { ClientContextProvider, useClientContext } from './useClientContext';
import { LibraryContextProvider } from './useLibraryContext';
import { useTheme } from './useTheme';
import './style.scss';
export const LIBRARY_UUID = 'f47c74cb-119d-42bf-b63d-87e2f9a2e3ba';
const spacedriveURL = (() => {
const currentURL = new URL(window.location.href);
if (import.meta.env.VITE_SDSERVER_ORIGIN) {
currentURL.host = import.meta.env.VITE_SDSERVER_ORIGIN;
} else if (import.meta.env.DEV) {
currentURL.host = 'localhost:8080';
}
return `${currentURL.origin}/spacedrive`;
})();
const platform: Platform = {
platform: 'web',
getThumbnailUrlByThumbKey: (keyParts) =>
`${spacedriveURL}/thumbnail/${keyParts.map((i) => encodeURIComponent(i)).join('/')}.webp`,
getFileUrl: (libraryId, locationLocalId, filePathId) =>
`${spacedriveURL}/file/${encodeURIComponent(libraryId)}/${encodeURIComponent(
locationLocalId
)}/${encodeURIComponent(filePathId)}`,
getFileUrlByPath: (path) => `${spacedriveURL}/local-file-by-path/${encodeURIComponent(path)}`,
openLink: (url) => window.open(url, '_blank')?.focus(),
confirm: (message, cb) => cb(window.confirm(message)),
// auth: {
// start(url) {
// return window.open(url);
// },
// finish(win: Window | null) {
// win?.close();
// }
// },
landingApiOrigin: 'https://spacedrive.com'
};
const queryClient = new QueryClient({
defaultOptions: {
queries: {
...(import.meta.env.VITE_SD_DEMO_MODE && {
refetchOnWindowFocus: false,
staleTime: Infinity,
cacheTime: Infinity,
networkMode: 'offlineFirst',
enabled: false
}),
networkMode: 'always'
},
mutations: {
networkMode: 'always'
}
// TODO: Mutations can't be globally disable which is annoying!
}
});
const cache = createCache();
export function Page() {
const { library } = useLibraryContext();
return (
<RspcProvider queryClient={queryClient}>
<PlatformProvider platform={platform}>
<QueryClientProvider client={queryClient}>
<cacheCtx.Provider value={cache}>
<ClientContextProvider currentLibraryId={LIBRARY_UUID}>
<ClientInner />
</ClientContextProvider>
</cacheCtx.Provider>
</QueryClientProvider>
</PlatformProvider>
</RspcProvider>
);
}
function ClientInner() {
useTheme();
const clientCtx = useClientContext();
return (
<Show when={clientCtx.library()}>
{(library) => (
<div class="bg-app">
<LibraryContextProvider library={library()}>
<Wrapper />
</LibraryContextProvider>
</div>
)}
</Show>
);
}
function Wrapper() {
const cache = useCache();
const locationQuery = createLibraryQuery(() => ['locations.get', 1]);
createComputed(() => cache.setNodes(locationQuery.data?.nodes ?? []));
const location = createMemo(() => cache.useCache(locationQuery.data?.item));
return <Show when={location()}>{(location) => <Inner location={location()} />}</Show>;
}
function Inner(props: { location: Location }) {
// const { library } = useLibraryContext();
const ctx = useRspcLibraryContext();
const query = createInfiniteQuery({
queryKey: ['search.paths', { library_id: library.uuid, arg }] as const,
queryFn: async ({ queryKey: [_, { arg }] }) => {
const result = await ctx!.client.query(['search.paths', arg]);
return result;
},
getNextPageParam: (lastPage) => {
if (arg.take === null || arg.take === undefined) return undefined;
if (lastPage.items.length < arg.take) return undefined;
else return lastPage.nodes[arg.take - 1];
},
...args
const paths = createPathsExplorerQuery({ arg: { take: 100 } });
const explorer = createExplorer<FilePathOrder>({
...paths,
settings: { orderingKeys: filePathOrderingKeysSchema },
isFetchingNextPage: paths.query.isFetchingNextPage,
parent: {
type: 'Location',
get location() {
return props.location;
}
}
});
const count = createLibraryQuery(['search.pathsCount', { filters: props.arg.filters }], {
enabled: query.isSuccess
});
const explorer = createExplorer();
return <Explorer />;
return (
<div class="flex h-screen w-screen">
<ExplorerContextProvider explorer={explorer()}>
<Explorer />
</ExplorerContextProvider>
</div>
);
}

View file

@ -0,0 +1,86 @@
import { createContext, useContext, type ParentProps } from 'solid-js';
// import { auth } from '@sd/client';
export type OperatingSystem = 'browser' | 'linux' | 'macOS' | 'windows' | 'unknown';
// Platform represents the underlying native layer the app is running on.
// This could be Tauri or web.
export type Platform = {
platform: 'web' | 'tauri'; // This represents the specific platform implementation
getThumbnailUrlByThumbKey: (thumbKey: string[]) => string;
getFileUrl: (libraryId: string, locationLocalId: number, filePathId: number) => string;
getFileUrlByPath: (path: string) => string;
openLink: (url: string) => void;
// Tauri patches `window.confirm` to return `Promise` not `bool`
confirm(msg: string, cb: (result: boolean) => void): void;
getOs?(): Promise<OperatingSystem>;
openDirectoryPickerDialog?(opts?: { title?: string; multiple: false }): Promise<null | string>;
openDirectoryPickerDialog?(opts?: {
title?: string;
multiple?: boolean;
}): Promise<null | string | string[]>;
openFilePickerDialog?(): Promise<null | string | string[]>;
saveFilePickerDialog?(opts?: { title?: string; defaultPath?: string }): Promise<string | null>;
showDevtools?(): void;
openPath?(path: string): void;
openLogsDir?(): void;
userHomeDir?(): Promise<string>;
// Opens a file path with a given ID
openFilePaths?(library: string, ids: number[]): any;
openEphemeralFiles?(paths: string[]): any;
revealItems?(
library: string,
items: (
| { Location: { id: number } }
| { FilePath: { id: number } }
| { Ephemeral: { path: string } }
)[]
): Promise<unknown>;
requestFdaMacos?(): void;
getFilePathOpenWithApps?(library: string, ids: number[]): Promise<unknown>;
reloadWebview?(): Promise<unknown>;
getEphemeralFilesOpenWithApps?(paths: string[]): Promise<unknown>;
openFilePathWith?(library: string, fileIdsAndAppUrls: [number, string][]): Promise<unknown>;
openEphemeralFileWith?(pathsAndUrls: [string, string][]): Promise<unknown>;
refreshMenuBar?(): Promise<unknown>;
setMenuBarItemState?(id: string, enabled: boolean): Promise<unknown>;
lockAppTheme?(themeType: 'Auto' | 'Light' | 'Dark'): any;
updater?: {
useSnapshot: () => UpdateStore;
checkForUpdate(): Promise<Update | null>;
installUpdate(): Promise<any>;
runJustUpdatedCheck(onViewChangelog: () => void): Promise<void>;
};
// auth: auth.ProviderConfig;
landingApiOrigin: string;
};
export type Update = { version: string; body: string | null };
export type UpdateStore =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'error' }
| { status: 'updateAvailable'; update: Update }
| { status: 'noUpdateAvailable' }
| { status: 'installing' };
// Keep this private and use through helpers below
const context = createContext<Platform>(undefined!);
// is a hook which allows you to fetch information about the current platform from the React context.
export function usePlatform(): Platform {
const ctx = useContext(context);
if (!ctx)
throw new Error(
"The 'PlatformProvider' has not been mounted above the current 'usePlatform' call."
);
return ctx;
}
// provides the platform context to the rest of the app through React context.
// Mount it near the top of your component tree.
export function PlatformProvider(props: ParentProps<{ platform: Platform }>) {
return <context.Provider value={props.platform}>{props.children}</context.Provider>;
}

View file

@ -0,0 +1,4 @@
import './patches';
import '@sd/ui/style/style.scss';
export * from './Page';

View file

@ -0,0 +1,86 @@
import { createContext, useContext, type ParentProps } from 'solid-js';
import { createStore, type SetStoreFunction, type Store } from 'solid-js/store';
export type CacheNode = {
__type: string;
__id: string;
} & Record<string, unknown>;
export const cacheCtx = createContext<Cache>();
export type UseCacheResult<T> = T extends (infer A)[]
? UseCacheResult<A>[]
: T extends object
? T extends { '__type': any; '__id': string; '#type': infer U }
? UseCacheResult<U>
: { [K in keyof T]: UseCacheResult<T[K]> }
: { [K in keyof T]: UseCacheResult<T[K]> };
function constructCache(nodes: Store<Nodes>, setNodes: SetStoreFunction<Nodes>) {
return {
getNodes: () => nodes,
getNode: (type: string, id: string) => nodes?.[type]?.[id] as unknown | undefined,
setNodes: (newNodes: CacheNode | CacheNode[]) => {
if (!Array.isArray(newNodes)) newNodes = [newNodes];
for (const node of newNodes) {
if (!(typeof node === 'object' || '__type' in node || '__id' in node))
throw new Error(
`Tried to 'setNodes' but encountered invalid node '${JSON.stringify(node)}'`
);
const { __type, __id, ...copy } = { ...node } as any;
if (!nodes[node.__type]) setNodes(node.__type, {});
setNodes(node.__type, node.__id, copy); // Be aware this is a merge, not a replace
}
},
useCache<T>(item: T) {
return restore(nodes, item) as UseCacheResult<T>;
}
};
}
export type Cache = ReturnType<typeof constructCache>;
export type Nodes = Record<string, Record<string, unknown>>;
export function createCache() {
const [nodes, setNodes] = createStore({} as Nodes);
const cache = constructCache(nodes, setNodes);
return {
...cache,
Provider: (props: ParentProps) => (
<cacheCtx.Provider value={cache}>{props.children}</cacheCtx.Provider>
)
};
}
export function useCache() {
const c = useContext(cacheCtx);
if (!c) throw new Error('Did you forget to mount `cache.Provider`?');
return c;
}
function restore(nodes: Store<Nodes>, item: unknown): unknown {
if (item === undefined || item === null) {
return item;
} else if (Array.isArray(item)) {
return item.map((v) => restore(nodes, v));
} else if (typeof item === 'object') {
if ('__type' in item && '__id' in item) {
if (typeof item.__type !== 'string') throw new Error('Invalid `__type`');
if (typeof item.__id !== 'string') throw new Error('Invalid `__id`');
const result = nodes?.[item.__type]?.[item.__id];
if (!result)
throw new Error(`Missing node for id '${item.__id}' of type '${item.__type}'`);
return result;
}
return Object.fromEntries(
Object.entries(item).map(([key, value]) => [key, restore(nodes, value)])
);
}
return item;
}

View file

View file

@ -1,5 +1,5 @@
---
import { Page } from "../Page";
import { Page } from "../Wrapper";
---
<!doctype html>

View file

@ -0,0 +1,22 @@
import { AlphaRSPCError, initRspc, wsBatchLink } from '@rspc/client/v2';
globalThis.isDev = import.meta.env.DEV;
globalThis.rspcLinks = [
// TODO
// loggerLink({
// enabled: () => getDebugState().rspcLogger
// }),
wsBatchLink({
url: (() => {
const currentURL = new URL(window.location.href);
currentURL.protocol = currentURL.protocol === 'https:' ? 'wss:' : 'ws:';
if (import.meta.env.VITE_SDSERVER_ORIGIN) {
currentURL.host = import.meta.env.VITE_SDSERVER_ORIGIN;
} else if (import.meta.env.DEV) {
currentURL.host = 'localhost:8080';
}
currentURL.pathname = 'rspc/ws';
return currentURL.href;
})()
})
];

View file

@ -1,10 +1,12 @@
import { type ProcedureDef } from '@rspc/client';
import { AlphaRSPCError, initRspc } from '@rspc/client/v2';
import { AlphaRSPCError, initRspc, wsBatchLink } from '@rspc/client/v2';
import { createReactQueryHooks, type Context } from '@rspc/solid';
import { QueryClient } from '@tanstack/react-query';
import { createContext, useContext, type ParentProps } from 'solid-js';
import { match, P } from 'ts-pattern';
import { currentLibraryCache, type LibraryArgs, type Procedures } from '@sd/client';
import { type LibraryArgs, type Procedures } from '@sd/client';
import { currentLibraryCache } from './useClientContext';
type NonLibraryProcedure<T extends keyof Procedures> =
| Exclude<Procedures[T], { input: LibraryArgs<any> }>
@ -71,11 +73,14 @@ const libraryHooks = createReactQueryHooks<LibraryProceduresDef>(libraryClient,
});
// TODO: Allow both hooks to use a unified context -> Right now they override each others local state
export function RspcProvider({ queryClient, children }: ParentProps<{ queryClient: QueryClient }>) {
export function RspcProvider(props: ParentProps<{ queryClient: QueryClient }>) {
return (
<libraryHooks.Provider client={libraryClient as any} queryClient={queryClient}>
<nonLibraryHooks.Provider client={nonLibraryClient as any} queryClient={queryClient}>
{children as any}
<libraryHooks.Provider client={libraryClient as any} queryClient={props.queryClient}>
<nonLibraryHooks.Provider
client={nonLibraryClient as any}
queryClient={props.queryClient}
>
{props.children}
</nonLibraryHooks.Provider>
</libraryHooks.Provider>
);

View file

@ -0,0 +1,384 @@
a,
button {
cursor: default !important;
}
body {
-webkit-user-select: none;
// font-family: 'InterVariable', sans-serif;
}
.app-background {
@apply bg-app;
}
.frame::before {
@apply bg-app-frame;
content: '';
pointer-events: none;
user-select: none;
position: absolute;
inset: 0px;
border-radius: inherit;
padding: 1px;
mask:
linear-gradient(black, black) content-box content-box,
linear-gradient(black, black);
mask-composite: xor;
z-index: 9999;
}
.has-blur-effects {
.app-background {
// adjust macOS blur intensity here
// @apply bg-app/[0.88];
@apply bg-app;
}
}
.no-scrollbar {
-ms-overflow-style: none; /* for Internet Explorer, Edge */
scrollbar-width: none; /* for Firefox */
overflow-y: scroll;
}
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar::-webkit-scrollbar {
display: none;
}
/*Tooltip*/
.TooltipContent {
animation-duration: 0.6s;
animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
}
.TooltipContent[data-side='top'] {
animation-name: slideUp;
}
.TooltipContent[data-side='bottom'] {
animation-name: slideDown;
}
.TooltipContent[data-side='right'] {
animation-name: slideRight;
animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes slideRight {
from {
opacity: 0;
transform: translateX(-10px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.custom-scroll {
-ms-overflow-style: none; /* for Internet Explorer, Edge */
scrollbar-width: none; /* for Firefox */
overflow-y: scroll;
}
::-webkit-scrollbar-corner {
background-color: transparent;
}
.explorer-scroll {
&::-webkit-scrollbar {
height: 6px;
width: 6px;
}
&::-webkit-scrollbar-track {
@apply rounded-[6px] bg-transparent;
margin-top: var(--scrollbar-margin-top);
margin-bottom: var(--scrollbar-margin-bottom);
}
&::-webkit-scrollbar-thumb {
@apply rounded-[6px] bg-app-explorerScrollbar;
}
}
.default-scroll {
&::-webkit-scrollbar {
height: 6px;
width: 8px;
}
&::-webkit-scrollbar-track {
@apply rounded-[6px] bg-transparent;
}
&::-webkit-scrollbar-thumb {
@apply rounded-[6px] bg-app-box;
}
}
.page-scroll {
&::-webkit-scrollbar {
height: 6px;
width: 8px;
}
&::-webkit-scrollbar-track {
@apply rounded-[6px] bg-transparent;
}
&::-webkit-scrollbar-thumb {
@apply rounded-[6px] bg-app-box;
}
}
.topbar-page-scroll {
&::-webkit-scrollbar {
height: 6px;
width: 8px;
}
&::-webkit-scrollbar-track {
@apply mt-[46px] rounded-[6px] bg-transparent;
}
&::-webkit-scrollbar-thumb {
@apply rounded-[6px] bg-app-box;
}
}
.quick-preview-images-scroll {
&::-webkit-scrollbar {
height: 6px;
width: 8px;
}
&::-webkit-scrollbar-track {
@apply rounded-[6px] bg-transparent;
}
&::-webkit-scrollbar-thumb {
@apply rounded-[6px] bg-white/20;
}
}
.job-manager-scroll {
&::-webkit-scrollbar {
height: 6px;
width: 8px;
}
&::-webkit-scrollbar-track {
@apply my-[2px] rounded-[6px] bg-transparent;
}
&::-webkit-scrollbar-thumb {
@apply rounded-[6px] bg-app-shade/20;
}
}
.inspector-scroll {
// overflow: overlay;
&::-webkit-scrollbar {
height: 6px;
width: 5px;
}
&::-webkit-scrollbar-track {
@apply my-[8px] bg-transparent;
}
&::-webkit-scrollbar-thumb {
@apply rounded-[6px] bg-app/70 opacity-0;
}
&:hover {
&::-webkit-scrollbar-thumb {
@apply opacity-100;
}
}
}
.overlay-scroll {
// overflow: overlay;
&::-webkit-scrollbar {
height: 6px;
width: 5px;
}
&::-webkit-scrollbar-track {
@apply my-[5px] bg-transparent;
}
&::-webkit-scrollbar-thumb {
@apply w-[5px] rounded-[6px] bg-black/70 opacity-0;
}
&:hover {
&::-webkit-scrollbar-thumb {
@apply opacity-100;
}
}
}
.textviewer-scroll {
&::-webkit-scrollbar {
height: 6px;
width: 8px;
}
&::-webkit-scrollbar-track {
@apply bg-transparent;
}
&::-webkit-scrollbar-thumb {
@apply rounded-md bg-app-box;
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes fadeOut {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@-webkit-keyframes slide-top {
0% {
-webkit-transform: translateY(0);
transform: translateY(0);
}
100% {
-webkit-transform: translateY(-50px);
transform: translateY(-50px);
}
}
@keyframes slide-top {
0% {
-webkit-transform: translateY(0);
transform: translateY(0);
}
100% {
-webkit-transform: translateY(-50px);
transform: translateY(-50px);
}
}
.dialog-overlay[data-state='open'] {
animation: fadeIn 200ms ease-out forwards;
}
.dialog-overlay[data-state='closed'] {
animation: fadeIn 200ms ease-out forwards;
}
.dialog-content[data-state='open'] {
-webkit-animation: slide-top 0.3s cubic-bezier(0.215, 0.61, 0.355, 1) both;
animation: slide-top 0.3s cubic-bezier(0.215, 0.61, 0.355, 1) both;
}
.dialog-content[data-state='closed'] {
animation: bounceDown 100ms ease-in forwards;
}
.picker {
position: relative;
}
.swatch {
width: 28px;
height: 28px;
border-radius: 8px;
border: 3px solid #fff;
box-shadow:
0 0 0 1px rgba(0, 0, 0, 0.1),
inset 0 0 0 1px rgba(0, 0, 0, 0.1);
cursor: pointer;
}
.popover {
position: absolute;
top: calc(100% + 2px);
left: 0;
border-radius: 9px;
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.15);
}
.react-colorful__saturation {
border-radius: 4px !important;
}
.react-colorful__saturation-pointer {
width: 12px !important;
height: 12px !important;
}
.react-colorful__hue,
.react-colorful__alpha {
margin-top: 12px !important;
height: 8px !important;
border-radius: 4px !important;
}
.react-colorful__hue-pointer,
.react-colorful__alpha-pointer {
height: 18px !important;
width: 8px !important;
border-radius: 3px !important;
}
.selecto-selection {
@apply rounded;
border-color: hsla(var(--color-accent));
background-color: hsla(var(--color-accent), 0.2) !important;
z-index: 10 !important;
}
.indeterminate-progress-bar {
/* Rounded border */
border-radius: 9999px;
/* Size */
height: 4px;
position: relative;
overflow: hidden;
}
.indeterminate-progress-bar__progress {
/* Rounded border */
border-radius: 9999px;
/* Absolute position */
position: absolute;
bottom: 0;
top: 0;
width: 50%;
/* Move the bar infinitely */
animation-duration: 2s;
animation-iteration-count: infinite;
animation-name: indeterminate-progress-bar;
}
@keyframes indeterminate-progress-bar {
from {
left: -50%;
}
to {
left: 100%;
}
}
.react-slidedown.search-options-slide {
transition-duration: 300ms;
transition-timing-function: cubic-bezier(0.85, 0, 0.15, 1);
}

View file

@ -0,0 +1,121 @@
import {
createContext,
createEffect,
createMemo,
useContext,
type Accessor,
type ParentProps
} from 'solid-js';
import { valtioPersist, type LibraryConfigWrapped } from '@sd/client';
import { useCache } from './cache';
import { createBridgeQuery, nonLibraryClient } from './rspc';
// The name of the localStorage key for caching library data
const libraryCacheLocalStorageKey = 'sd-library-list2'; // `2` is because the format of this underwent a breaking change when introducing normalised caching
export const useCachedLibraries = () => {
const query = createBridgeQuery(() => ['library.list'] as const, {
keepPreviousData: true
// initialData: () => {
// const cachedData = localStorage.getItem(libraryCacheLocalStorageKey);
// if (cachedData) {
// // If we fail to load cached data, it's fine
// try {
// return JSON.parse(cachedData);
// } catch (e) {
// console.error("Error loading cached 'sd-library-list' data", e);
// }
// }
// return undefined;
// },
// onSuccess: (data) => localStorage.setItem(libraryCacheLocalStorageKey, JSON.stringify(data))
});
const cache = useCache();
createMemo(() => cache.setNodes(query.data?.nodes ?? []));
const libraries = createMemo(() => cache.useCache(query.data?.items ?? []));
return {
...query,
get data() {
return libraries();
}
};
};
// export async function getCachedLibraries(cache: NormalisedCache) {
// const cachedData = localStorage.getItem(libraryCacheLocalStorageKey);
// if (cachedData) {
// // If we fail to load cached data, it's fine
// try {
// const data = JSON.parse(cachedData);
// cache.withNodes(data.nodes);
// return cache.withCache(data.items) as LibraryConfigWrapped[];
// } catch (e) {
// console.error("Error loading cached 'sd-library-list' data", e);
// }
// }
// const result = await nonLibraryClient.query(['library.list']);
// cache.withNodes(result.nodes);
// const libraries = cache.withCache(result.items);
// localStorage.setItem(libraryCacheLocalStorageKey, JSON.stringify(result));
// return libraries;
// }
export interface ClientContext {
currentLibraryId: string | null;
libraries: ReturnType<typeof useCachedLibraries>;
library: Accessor<LibraryConfigWrapped | null | undefined>;
}
const ClientContext = createContext<ClientContext>(null!);
interface ClientContextProviderProps extends ParentProps {
currentLibraryId: string | null;
}
export const ClientContextProvider = (props: ClientContextProviderProps) => {
const libraries = useCachedLibraries();
createEffect(() => {
currentLibraryCache.id = props.currentLibraryId;
});
const library = createMemo(
() =>
(libraries.data && libraries.data.find((l) => l.uuid === props.currentLibraryId)) ||
null
);
return (
<ClientContext.Provider
value={{
currentLibraryId: props.currentLibraryId,
libraries,
library
}}
>
{props.children}
</ClientContext.Provider>
);
};
export const useClientContext = () => {
const ctx = useContext(ClientContext);
if (ctx === undefined) throw new Error("'ClientContextProvider' not mounted");
return ctx;
};
export const useCurrentLibraryId = () => useClientContext().currentLibraryId;
export const currentLibraryCache = valtioPersist('sd-current-library', {
id: null as string | null
});

View file

@ -0,0 +1,47 @@
import { createContext, useContext, type ParentProps } from 'solid-js';
import type { LibraryConfigWrapped } from '@sd/client';
import { createBridgeSubscription } from './rspc';
import { useClientContext, type ClientContext } from './useClientContext';
export interface LibraryContext {
library: LibraryConfigWrapped;
libraries: ClientContext['libraries'];
}
const LibraryContext = createContext<LibraryContext>(null!);
interface LibraryContextProviderProps extends ParentProps {
library: LibraryConfigWrapped;
}
export const LibraryContextProvider = (props: LibraryContextProviderProps) => {
const { libraries } = useClientContext();
// We put this into context because each hook creates a new subscription which means we get duplicate events from the backend if we don't do this
// TODO: This should probs be a library subscription - https://linear.app/spacedriveapp/issue/ENG-724/locationsonline-should-be-a-library-not-a-bridge-subscription
createBridgeSubscription(() => ['locations.online'] as const, {
onData: (d) => {
// getLibraryStore().onlineLocations = d;
}
});
return (
<LibraryContext.Provider value={{ library: props.library, libraries }}>
{props.children}
</LibraryContext.Provider>
);
};
export const useLibraryContext = () => {
const ctx = useContext(LibraryContext);
if (ctx === undefined) throw new Error("'LibraryContextProvider' not mounted");
return ctx;
};
// export function useOnlineLocations() {
// const { onlineLocations } = useLibraryStore();
// return onlineLocations;
// }

View file

@ -0,0 +1,64 @@
import { createEffect } from 'solid-js';
import { createMutable } from 'solid-js/store';
import { getThemeStore, useThemeStore } from '@sd/client';
import { usePlatform } from './Platform';
export const themeStore = createMutable({
theme: 'dark',
syncThemeWithSystem: false,
hueValue: 235
});
export function useTheme() {
// const themeStore = useThemeStore();
const platform = usePlatform();
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)');
createEffect(() => {
const handleThemeChange = () => {
if (themeStore.syncThemeWithSystem) {
platform.lockAppTheme?.('Auto');
if (systemTheme.matches) {
document.documentElement.classList.remove('vanilla-theme');
document.documentElement.style.setProperty(
'--dark-hue',
getThemeStore().hueValue.toString()
);
getThemeStore().theme = 'dark';
} else {
document.documentElement.classList.add('vanilla-theme');
document.documentElement.style.setProperty(
'--light-hue',
getThemeStore().hueValue.toString()
);
getThemeStore().theme = 'vanilla';
}
} else {
if (themeStore.theme === 'dark') {
document.documentElement.classList.remove('vanilla-theme');
document.documentElement.style.setProperty(
'--dark-hue',
getThemeStore().hueValue.toString()
);
platform.lockAppTheme?.('Dark');
} else if (themeStore.theme === 'vanilla') {
document.documentElement.classList.add('vanilla-theme');
document.documentElement.style.setProperty(
'--light-hue',
getThemeStore().hueValue.toString()
);
platform.lockAppTheme?.('Light');
}
}
};
handleThemeChange();
systemTheme.addEventListener('change', handleThemeChange);
return () => {
systemTheme.removeEventListener('change', handleThemeChange);
};
});
}

View file

@ -1,8 +1,3 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}'],
theme: {
extend: {},
},
plugins: [],
}
import tailwindFactory from '@sd/ui/tailwind';
export default tailwindFactory('web');

View file

@ -1,30 +0,0 @@
import react from '@astrojs/react';
// import solid from '@astrojs/solid-js';
import tailwind from '@astrojs/tailwind';
import { defineConfig } from 'astro/config';
import { mergeConfig } from 'vite';
import baseConfig from '../../packages/config/vite';
import relativeAliasResolver from '../../packages/config/vite/relativeAliasResolver';
// https://astro.build/config
export default defineConfig({
integrations: [
react({
// exclude: ['**/*.solid.*']
}),
// solid({
// include: ['**/*.solid.*']
// }),
tailwind()
],
// server: {
// port: 8002
// },
vite: mergeConfig(baseConfig, {
resolve: {
// BE REALLY DAMN CAREFUL MODIFYING THIS: https://github.com/spacedriveapp/spacedrive/pull/1353
alias: [relativeAliasResolver]
}
})
});

View file

@ -3,7 +3,7 @@
"private": true,
"version": "0.0.0",
"scripts": {
"dev": "astro dev",
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"test": "VITE_SD_DEMO_MODE=true playwright test",
@ -14,7 +14,7 @@
"@astrojs/solid-js": "^3.0.2",
"@astrojs/tailwind": "^5.0.3",
"@fontsource/inter": "^4.5.15",
"@rspc/client": "0.0.0-main-45466c86",
"@rspc/client": "0.0.0-main-b8b35d28",
"@sd/client": "workspace:*",
"@sd/interface": "workspace:*",
"@tanstack/react-query": "^4.36.1",

17
apps/web/src/index.tsx Normal file
View file

@ -0,0 +1,17 @@
// WARNING: BE CAREFUL SAVING THIS FILE WITH A FORMATTER ENABLED. The import order is important and goes against prettier's recommendations.
import React, { Suspense } from 'react';
import ReactDOM from 'react-dom/client';
import '@sd/ui/style/style.scss';
import '~/patches';
import { App } from './App';
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
root.render(
<React.StrictMode>
<Suspense>
<App />
</Suspense>
</React.StrictMode>
);

View file

@ -11,8 +11,8 @@
"typecheck": "tsc -b"
},
"dependencies": {
"@rspc/client": "0.0.0-main-45466c86",
"@rspc/react": "0.0.0-main-45466c86",
"@rspc/client": "0.0.0-main-b8b35d28",
"@rspc/react": "0.0.0-main-b8b35d28",
"@tanstack/react-query": "^4.36.1",
"@zxcvbn-ts/core": "^3.0.4",
"@zxcvbn-ts/language-common": "^3.0.4",

View file

@ -1,4 +1,4 @@
// import react from '@vitejs/plugin-react';
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
import { createHtmlPlugin } from 'vite-plugin-html';
// import solid from 'vite-plugin-solid';
@ -8,12 +8,9 @@ import tsconfigPaths from 'vite-tsconfig-paths';
export default defineConfig({
plugins: [
tsconfigPaths(),
// react({
// exclude: ['**/*.solid.*']
// }),
// solid({
// include: ['**/*.solid.*']
// }),
react({
exclude: ['**/*.solid.*']
}),
svg({ svgrOptions: { icon: true } }),
createHtmlPlugin({
minify: true

View file

@ -81,11 +81,11 @@ importers:
specifier: ^1.14.0
version: 1.14.0
'@rspc/client':
specifier: 0.0.0-main-45466c86
version: 0.0.0-main-45466c86
specifier: 0.0.0-main-b8b35d28
version: 0.0.0-main-b8b35d28
'@rspc/tauri':
specifier: 0.0.0-main-45466c86
version: 0.0.0-main-45466c86(@tauri-apps/api@1.5.1)
specifier: 0.0.0-main-b8b35d28
version: 0.0.0-main-b8b35d28(@tauri-apps/api@1.5.1)
'@sd/client':
specifier: workspace:*
version: link:../../packages/client
@ -350,11 +350,11 @@ importers:
specifier: ^6.3.20
version: 6.3.20(@react-navigation/native@6.1.9)(react-native-gesture-handler@2.12.1)(react-native-safe-area-context@4.6.3)(react-native-screens@3.22.1)(react-native@0.72.6)(react@18.2.0)
'@rspc/client':
specifier: 0.0.0-main-45466c86
version: 0.0.0-main-45466c86
specifier: 0.0.0-main-b8b35d28
version: 0.0.0-main-b8b35d28
'@rspc/react':
specifier: 0.0.0-main-45466c86
version: 0.0.0-main-45466c86(@rspc/client@0.0.0-main-45466c86)(@tanstack/react-query@4.36.1)(react@18.2.0)
specifier: 0.0.0-main-b8b35d28
version: 0.0.0-main-b8b35d28(@rspc/client@0.0.0-main-b8b35d28)(@tanstack/react-query@4.36.1)(react@18.2.0)
'@sd/assets':
specifier: workspace:*
version: link:../../packages/assets
@ -576,8 +576,8 @@ importers:
specifier: ^4.5.15
version: 4.5.15
'@rspc/client':
specifier: 0.0.0-main-45466c86
version: 0.0.0-main-45466c86
specifier: 0.0.0-main-b8b35d28
version: 0.0.0-main-b8b35d28
'@sd/client':
specifier: workspace:*
version: link:../../packages/client
@ -670,14 +670,17 @@ importers:
specifier: ^5.0.3
version: 5.0.3(astro@4.0.5)(tailwindcss@3.3.6)
'@rspc/solid':
specifier: 0.0.0-main-45466c86
version: 0.0.0-main-45466c86(@rspc/client@0.0.0-main-45466c86)(@tanstack/solid-query@4.36.1)(solid-js@1.8.7)
specifier: 0.0.0-main-b8b35d28
version: 0.0.0-main-b8b35d28(@rspc/client@0.0.0-main-b8b35d28)(@tanstack/solid-query@4.36.1)(solid-js@1.8.7)
'@sd/assets':
specifier: workspace:^
version: link:../../packages/assets
'@sd/client':
specifier: workspace:^
version: link:../../packages/client
'@sd/ui':
specifier: workspace:^
version: link:../../packages/ui
'@solid-primitives/event-listener':
specifier: ^2.3.0
version: 2.3.0(solid-js@1.8.7)
@ -940,11 +943,11 @@ importers:
packages/client:
dependencies:
'@rspc/client':
specifier: 0.0.0-main-45466c86
version: 0.0.0-main-45466c86
specifier: 0.0.0-main-b8b35d28
version: 0.0.0-main-b8b35d28
'@rspc/react':
specifier: 0.0.0-main-45466c86
version: 0.0.0-main-45466c86(@rspc/client@0.0.0-main-45466c86)(@tanstack/react-query@4.36.1)(react@18.2.0)
specifier: 0.0.0-main-b8b35d28
version: 0.0.0-main-b8b35d28(@rspc/client@0.0.0-main-b8b35d28)(@tanstack/react-query@4.36.1)(react@18.2.0)
'@tanstack/react-query':
specifier: ^4.36.1
version: 4.36.1(react-dom@18.2.0)(react-native@0.72.6)(react@18.2.0)
@ -4681,7 +4684,7 @@ packages:
magic-string: 0.27.0
react-docgen-typescript: 2.2.2(typescript@5.3.3)
typescript: 5.3.3
vite: 5.0.9(less@4.2.0)
vite: 5.0.9(@types/node@18.17.19)
/@jridgewell/gen-mapping@0.3.3:
resolution: {integrity: sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==}
@ -7154,40 +7157,40 @@ packages:
requiresBuild: true
optional: true
/@rspc/client@0.0.0-main-45466c86:
resolution: {integrity: sha512-1a3+jSJXXcHyoMYrqlb5DModVf5m7S4Y7a6BaUHmkhfXG4rttvthFHNAU1ODMpbI371egEePFhiVR8SnPsXe6Q==}
/@rspc/client@0.0.0-main-b8b35d28:
resolution: {integrity: sha512-wXBZ+KDBzBfXXKr2GWAe/UF+D5jLl1vM7mBTuFJsEV4ihquu2hzxAQBPuBE3j6JC7SIWzhQ+hodzGFUCL04Rsg==}
dev: false
/@rspc/react@0.0.0-main-45466c86(@rspc/client@0.0.0-main-45466c86)(@tanstack/react-query@4.36.1)(react@18.2.0):
resolution: {integrity: sha512-4b3SBm6KFS0fZ0JwdZPFZeb+ZG/FgXn3Qb4DJga5ioGr11YsDGMgeEyaxfC8dLSHtT5tj8vFqX/a6LoWk0Vqbw==}
/@rspc/react@0.0.0-main-b8b35d28(@rspc/client@0.0.0-main-b8b35d28)(@tanstack/react-query@4.36.1)(react@18.2.0):
resolution: {integrity: sha512-yjKtZkziLvUI3AyKAdN8l32O3yS2FBqKda24MWDcN/YWjFVw15Enfr9C2bGtuqrZZ/LKabrG67EnKWqGvTWkCQ==}
peerDependencies:
'@rspc/client': 0.0.0-main-45466c86
'@rspc/client': 0.0.0-main-b8b35d28
'@tanstack/react-query': ^4.26.0
react: ^18.2.0
dependencies:
'@rspc/client': 0.0.0-main-45466c86
'@rspc/client': 0.0.0-main-b8b35d28
'@tanstack/react-query': 4.36.1(react-dom@18.2.0)(react-native@0.72.6)(react@18.2.0)
react: 18.2.0
dev: false
/@rspc/solid@0.0.0-main-45466c86(@rspc/client@0.0.0-main-45466c86)(@tanstack/solid-query@4.36.1)(solid-js@1.8.7):
resolution: {integrity: sha512-5rfeivH4I2LrMSx5Igfx2FfLn7F6s5OzVy9VtaJ6C4K/Yh9Y2AufR0yQwEApoUrfexGWbecmmoW+yPbDqaJvpA==}
/@rspc/solid@0.0.0-main-b8b35d28(@rspc/client@0.0.0-main-b8b35d28)(@tanstack/solid-query@4.36.1)(solid-js@1.8.7):
resolution: {integrity: sha512-KjssarTWhoj8chAoESekTfucy/vg/eum+5Ghp1vMfUQWhGQ7f6SOXqeGQefOz0itIBAMCmQn+R03uFm59+GMCQ==}
peerDependencies:
'@rspc/client': 0.0.0-main-45466c86
'@rspc/client': 0.0.0-main-b8b35d28
'@tanstack/solid-query': ^4.6.0
solid-js: ^1.6.11
dependencies:
'@rspc/client': 0.0.0-main-45466c86
'@rspc/client': 0.0.0-main-b8b35d28
'@tanstack/solid-query': 4.36.1(solid-js@1.8.7)
solid-js: 1.8.7
dev: false
/@rspc/tauri@0.0.0-main-45466c86(@tauri-apps/api@1.5.1):
resolution: {integrity: sha512-B+/PcZjxuVTQEtEgBV7UXYwf1uGNbN34+6aqbc5oxxifLDDn0MxVGmEju3Y518sYCfuYaIF8rYDl+oPM9a7NSg==}
/@rspc/tauri@0.0.0-main-b8b35d28(@tauri-apps/api@1.5.1):
resolution: {integrity: sha512-uzBBxsP8ENBUs16j5KXni72WKtVdi37uA/+GA1rf2M/qJvnc8W+slXrI4PXL9CBHnGRbwXk/gM1z9AdadqsFRw==}
peerDependencies:
'@tauri-apps/api': ^1.2.0
dependencies:
'@rspc/client': 0.0.0-main-45466c86
'@rspc/client': 0.0.0-main-b8b35d28
'@tauri-apps/api': 1.5.1
dev: false
@ -7834,7 +7837,7 @@ packages:
magic-string: 0.30.5
rollup: 3.29.4
typescript: 5.3.3
vite: 5.0.9(less@4.2.0)
vite: 5.0.9(@types/node@18.17.19)
transitivePeerDependencies:
- encoding
- supports-color
@ -8176,7 +8179,7 @@ packages:
react: 18.2.0
react-docgen: 7.0.1
react-dom: 18.2.0(react@18.2.0)
vite: 5.0.9(less@4.2.0)
vite: 5.0.9(@types/node@18.17.19)
transitivePeerDependencies:
- '@preact/preset-vite'
- encoding
@ -9304,7 +9307,7 @@ packages:
'@babel/plugin-transform-react-jsx-source': 7.23.3(@babel/core@7.23.6)
magic-string: 0.27.0
react-refresh: 0.14.0
vite: 5.0.9(less@4.2.0)
vite: 5.0.9(@types/node@18.17.19)
transitivePeerDependencies:
- supports-color
@ -22537,6 +22540,7 @@ packages:
rollup: 4.9.0
optionalDependencies:
fsevents: 2.3.3
dev: true
/vite@5.0.9(sass@1.69.5):
resolution: {integrity: sha512-wVqMd5kp28QWGgfYPDfrj771VyHTJ4UDlCteLH7bJDGDEamaz5hV8IX6h1brSGgnnyf9lI2RnzXq/JmD0c2wwg==}