639 lines
18 KiB
TypeScript
639 lines
18 KiB
TypeScript
import { For, Show, createSignal, type JSX } from "solid-js";
|
|
import { Portal } from "solid-js/web";
|
|
import { ChevronRight, Plus, X } from "../../../lib/icons";
|
|
import { useAppShellData } from "../data/app-shell.context";
|
|
import { createLongPressGesture } from "../createLongPressGesture";
|
|
import {
|
|
createWorkspaceStaticTarget,
|
|
createWorkspaceSurfaceTarget,
|
|
createWorkspaceTreeTarget,
|
|
getWorkspaceItemTypeDefinition,
|
|
getWorkspaceNodeIcon,
|
|
workspaceStaticItems,
|
|
type SidebarItem,
|
|
type WorkspaceContextMenuAction,
|
|
type WorkspaceContextMenuTarget,
|
|
type WorkspaceItemTypeId,
|
|
type WorkspaceStaticItem,
|
|
type WorkspaceTreeNode,
|
|
} from "../data/shell.data";
|
|
import { useWorkspaceTreeData } from "../shared/useWorkspaceTreeData";
|
|
import { WorkspaceMobileActionSheet } from "../WorkspaceMobileActionSheet/WorkspaceMobileActionSheet";
|
|
import styles from "./MobileWorkspaceBrowser.module.scss";
|
|
|
|
type MobileWorkspaceBrowserProps = {
|
|
open: boolean;
|
|
onClose: VoidFunction;
|
|
};
|
|
|
|
type MobileWorkspaceDialogState =
|
|
| {
|
|
kind: "text";
|
|
title: string;
|
|
message: string;
|
|
confirmLabel: string;
|
|
initialValue: string;
|
|
onConfirm: (value: string) => void;
|
|
}
|
|
| {
|
|
kind: "confirm";
|
|
title: string;
|
|
message: string;
|
|
confirmLabel: string;
|
|
tone?: "danger";
|
|
onConfirm: () => void;
|
|
};
|
|
|
|
type MobileMoveTargetState = {
|
|
kind: "folder" | "item";
|
|
id: string;
|
|
label: string;
|
|
};
|
|
|
|
type MobileMoveDestination = {
|
|
id: string | null;
|
|
label: string;
|
|
depth: number;
|
|
meta?: string;
|
|
};
|
|
|
|
const collectMoveDestinations = (
|
|
nodes: readonly WorkspaceTreeNode[],
|
|
movingTarget: MobileMoveTargetState,
|
|
depth = 0,
|
|
ancestorBlocked = false,
|
|
): MobileMoveDestination[] => {
|
|
const destinations: MobileMoveDestination[] = [];
|
|
|
|
for (const node of nodes) {
|
|
if (node.kind !== "folder") {
|
|
continue;
|
|
}
|
|
|
|
const isBlockedFolder = movingTarget.kind === "folder" && node.id === movingTarget.id;
|
|
if (!ancestorBlocked && !isBlockedFolder) {
|
|
destinations.push({
|
|
id: node.id,
|
|
label: node.label,
|
|
depth,
|
|
meta: "Folder",
|
|
});
|
|
}
|
|
|
|
destinations.push(
|
|
...collectMoveDestinations(node.children ?? [], movingTarget, depth + 1, ancestorBlocked || isBlockedFolder),
|
|
);
|
|
}
|
|
|
|
return destinations;
|
|
};
|
|
|
|
const findTreeNodeById = (nodes: readonly WorkspaceTreeNode[], nodeId: string): WorkspaceTreeNode | null => {
|
|
for (const node of nodes) {
|
|
if (node.id === nodeId) {
|
|
return node;
|
|
}
|
|
|
|
if (node.kind !== "folder") {
|
|
continue;
|
|
}
|
|
|
|
const nestedMatch = findTreeNodeById(node.children ?? [], nodeId);
|
|
if (nestedMatch) {
|
|
return nestedMatch;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
const isDangerDialogState = (state: MobileWorkspaceDialogState): boolean => state.kind === "confirm" && state.tone === "danger";
|
|
|
|
const TreeRow = (props: { node: WorkspaceTreeNode; depth?: number }): JSX.Element => {
|
|
const depth = props.depth ?? 0;
|
|
const Icon = getWorkspaceNodeIcon(props.node);
|
|
const hasChildren = (props.node.children?.length ?? 0) > 0;
|
|
|
|
return (
|
|
<button
|
|
classList={{
|
|
[styles.treeRow]: true,
|
|
[styles.treeRowActive]: props.node.active ?? false,
|
|
[styles.treeRowBranch]: hasChildren,
|
|
}}
|
|
type="button"
|
|
style={{ "--tree-depth": `${depth}` }}
|
|
data-slot="mobile-workspace-tree-row"
|
|
data-kind={props.node.kind}
|
|
data-item-type={props.node.kind === "item" ? props.node.itemType : undefined}
|
|
data-active={props.node.active ? "true" : "false"}
|
|
>
|
|
<span class={styles.treeRowLead}>
|
|
<Icon size={16} strokeWidth={2} />
|
|
<span class={styles.treeLabel}>{props.node.label}</span>
|
|
</span>
|
|
|
|
<span class={styles.treeRowTrail}>
|
|
<Show when={props.node.meta}>
|
|
<span class={styles.treeMeta}>{props.node.meta}</span>
|
|
</Show>
|
|
<Show when={hasChildren}>
|
|
<ChevronRight size={14} strokeWidth={2} class={styles.treeChevron} />
|
|
</Show>
|
|
</span>
|
|
</button>
|
|
);
|
|
};
|
|
|
|
const StaticRow = (props: { item: SidebarItem }): JSX.Element => {
|
|
const Icon = props.item.icon;
|
|
|
|
return (
|
|
<button classList={{ [styles.treeRow]: true, [styles.treeRowActive]: props.item.active ?? false }} type="button" style={{ "--tree-depth": "0" }} data-slot="mobile-workspace-static-row" data-active={props.item.active ? "true" : "false"}>
|
|
<span class={styles.treeRowLead}>
|
|
<Icon size={16} strokeWidth={2} />
|
|
<span class={styles.treeLabel}>{props.item.label}</span>
|
|
</span>
|
|
<span class={styles.treeRowTrail}>
|
|
<Show when={props.item.meta}>
|
|
<span class={styles.treeMeta}>{props.item.meta}</span>
|
|
</Show>
|
|
<ChevronRight size={14} strokeWidth={2} class={styles.treeChevron} />
|
|
</span>
|
|
</button>
|
|
);
|
|
};
|
|
|
|
const WorkspaceStaticRow = (props: {
|
|
item: WorkspaceStaticItem;
|
|
onOpenActionSheet: (target: WorkspaceContextMenuTarget) => void;
|
|
}): JSX.Element => {
|
|
const target = createWorkspaceStaticTarget(props.item);
|
|
const longPress = createLongPressGesture({
|
|
onLongPress: () => {
|
|
props.onOpenActionSheet(target);
|
|
},
|
|
});
|
|
|
|
return (
|
|
<li
|
|
class={styles.treeListItem}
|
|
data-slot="mobile-workspace-static-item"
|
|
data-target-kind={target.kind}
|
|
onContextMenu={(event): void => {
|
|
event.preventDefault();
|
|
props.onOpenActionSheet(target);
|
|
}}
|
|
{...longPress}
|
|
>
|
|
<StaticRow item={props.item} />
|
|
</li>
|
|
);
|
|
};
|
|
|
|
const WorkspaceTreeBranch = (props: {
|
|
nodes: readonly WorkspaceTreeNode[];
|
|
depth?: number;
|
|
onOpenActionSheet: (target: WorkspaceContextMenuTarget) => void;
|
|
}): JSX.Element => {
|
|
const depth = props.depth ?? 0;
|
|
|
|
return (
|
|
<For each={props.nodes}>
|
|
{(node): JSX.Element => {
|
|
const target = createWorkspaceTreeTarget(node);
|
|
const longPress = createLongPressGesture({
|
|
onLongPress: () => {
|
|
props.onOpenActionSheet(target);
|
|
},
|
|
});
|
|
|
|
return (
|
|
<li
|
|
class={styles.treeListItem}
|
|
data-slot="mobile-workspace-tree-item"
|
|
data-kind={node.kind}
|
|
data-item-type={node.kind === "item" ? node.itemType : undefined}
|
|
onContextMenu={(event): void => {
|
|
event.preventDefault();
|
|
props.onOpenActionSheet(target);
|
|
}}
|
|
{...longPress}
|
|
>
|
|
<TreeRow node={node} depth={depth} />
|
|
|
|
<Show when={node.children?.length}>
|
|
<ul class={styles.treeListNested}>
|
|
<WorkspaceTreeBranch nodes={node.children ?? []} depth={depth + 1} onOpenActionSheet={props.onOpenActionSheet} />
|
|
</ul>
|
|
</Show>
|
|
</li>
|
|
);
|
|
}}
|
|
</For>
|
|
);
|
|
};
|
|
|
|
export const MobileWorkspaceBrowser = (props: MobileWorkspaceBrowserProps): JSX.Element => {
|
|
const appShellData = useAppShellData();
|
|
const [actionSheetTarget, setActionSheetTarget] = createSignal<WorkspaceContextMenuTarget | null>(null);
|
|
const [dialogState, setDialogState] = createSignal<MobileWorkspaceDialogState | null>(null);
|
|
const [dialogValue, setDialogValue] = createSignal("");
|
|
const [moveTarget, setMoveTarget] = createSignal<MobileMoveTargetState | null>(null);
|
|
const { workspaceTreeNodes, createFolder, renameFolder, deleteFolder, moveFolder, createItem, deleteItem, moveItem } = useWorkspaceTreeData({
|
|
activeProjectId: () => appShellData.activeProject().id,
|
|
fallbackWorkspaceTree: () => appShellData.workspaceTree(),
|
|
});
|
|
const workspaceTarget = () => createWorkspaceSurfaceTarget(appShellData.activeProject());
|
|
|
|
const moveDestinations = () => {
|
|
const target = moveTarget();
|
|
if (!target) {
|
|
return [] as MobileMoveDestination[];
|
|
}
|
|
|
|
return [
|
|
{ id: null, label: "Items root", depth: 0, meta: "Root" },
|
|
...collectMoveDestinations(workspaceTreeNodes(), target),
|
|
];
|
|
};
|
|
|
|
const resolveCreateItemType = (actionId: string): WorkspaceItemTypeId | null => {
|
|
switch (actionId) {
|
|
case "create-doc":
|
|
return "core.doc";
|
|
case "create-board":
|
|
return "core.board.kanban";
|
|
case "create-list-board":
|
|
return "core.board.list";
|
|
default:
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const createPersistedItem = (itemType: WorkspaceItemTypeId, parentId: string | null): void => {
|
|
const definition = getWorkspaceItemTypeDefinition(itemType);
|
|
void createItem(definition.defaultCreateLabel, itemType, parentId);
|
|
};
|
|
|
|
const openActionSheet = (target: WorkspaceContextMenuTarget): void => {
|
|
setActionSheetTarget(target);
|
|
};
|
|
|
|
const closeActionSheet = (): void => {
|
|
setActionSheetTarget(null);
|
|
};
|
|
|
|
const closeMoveSheet = (): void => {
|
|
setMoveTarget(null);
|
|
};
|
|
|
|
const openWorkspaceActionSheet = (): void => {
|
|
openActionSheet(workspaceTarget());
|
|
};
|
|
|
|
const closeDialog = (): void => {
|
|
setDialogState(null);
|
|
setDialogValue("");
|
|
};
|
|
|
|
const openMoveSheet = (target: MobileMoveTargetState): void => {
|
|
setMoveTarget(target);
|
|
};
|
|
|
|
const openTextDialog = (config: Omit<Extract<MobileWorkspaceDialogState, { kind: "text" }>, "kind">): void => {
|
|
setDialogValue(config.initialValue);
|
|
setDialogState({ kind: "text", ...config });
|
|
};
|
|
|
|
const openConfirmDialog = (config: Omit<Extract<MobileWorkspaceDialogState, { kind: "confirm" }>, "kind">): void => {
|
|
setDialogValue("");
|
|
setDialogState({ kind: "confirm", ...config });
|
|
};
|
|
|
|
const submitDialog = (): void => {
|
|
const state = dialogState();
|
|
if (!state) {
|
|
return;
|
|
}
|
|
|
|
if (state.kind === "text") {
|
|
const value = dialogValue().trim();
|
|
if (!value) {
|
|
return;
|
|
}
|
|
|
|
closeDialog();
|
|
state.onConfirm(value);
|
|
return;
|
|
}
|
|
|
|
closeDialog();
|
|
state.onConfirm();
|
|
};
|
|
|
|
const handleMoveDestinationSelect = (destinationId: string | null): void => {
|
|
const target = moveTarget();
|
|
if (!target) {
|
|
return;
|
|
}
|
|
|
|
const destinationNode = destinationId ? findTreeNodeById(workspaceTreeNodes(), destinationId) : null;
|
|
const targetIndex = destinationNode?.kind === "folder"
|
|
? destinationNode.children?.length ?? 0
|
|
: destinationId
|
|
? 0
|
|
: workspaceTreeNodes().length;
|
|
|
|
closeMoveSheet();
|
|
|
|
if (target.kind === "folder") {
|
|
void moveFolder(target.id, destinationId, targetIndex);
|
|
return;
|
|
}
|
|
|
|
void moveItem(target.id, destinationId, targetIndex);
|
|
};
|
|
|
|
const handleActionSelect = (action: WorkspaceContextMenuAction, target: WorkspaceContextMenuTarget): void => {
|
|
const createItemType = resolveCreateItemType(action.id);
|
|
if (createItemType) {
|
|
switch (target.kind) {
|
|
case "workspace":
|
|
case "home":
|
|
createPersistedItem(createItemType, null);
|
|
return;
|
|
case "folder":
|
|
createPersistedItem(createItemType, target.id);
|
|
return;
|
|
case "settings":
|
|
case "item":
|
|
return;
|
|
}
|
|
}
|
|
|
|
switch (action.id) {
|
|
case "new-folder": {
|
|
if (target.kind === "settings" || target.kind === "item") {
|
|
return;
|
|
}
|
|
|
|
openTextDialog({
|
|
title: "New folder",
|
|
message: target.kind === "folder" ? `Create a folder inside "${target.label}".` : "Create a folder at the root of Items.",
|
|
confirmLabel: "Create",
|
|
initialValue: "Untitled folder",
|
|
onConfirm: (name) => {
|
|
void createFolder(name, target.kind === "folder" ? target.id : null);
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
case "rename-folder": {
|
|
if (target.kind !== "folder") {
|
|
return;
|
|
}
|
|
|
|
openTextDialog({
|
|
title: "Rename folder",
|
|
message: `Update the name for "${target.label}".`,
|
|
confirmLabel: "Save",
|
|
initialValue: target.label,
|
|
onConfirm: (name) => {
|
|
if (name === target.label) {
|
|
return;
|
|
}
|
|
void renameFolder(target.id, name);
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
case "move-folder": {
|
|
if (target.kind !== "folder") {
|
|
return;
|
|
}
|
|
|
|
openMoveSheet({
|
|
kind: "folder",
|
|
id: target.id,
|
|
label: target.label,
|
|
});
|
|
return;
|
|
}
|
|
case "delete-folder": {
|
|
if (target.kind !== "folder") {
|
|
return;
|
|
}
|
|
|
|
openConfirmDialog({
|
|
title: "Delete folder?",
|
|
message: `"${target.label}" and everything inside it will be removed.`,
|
|
confirmLabel: "Delete",
|
|
tone: "danger",
|
|
onConfirm: () => {
|
|
void deleteFolder(target.id);
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
case "move-doc":
|
|
case "move-board":
|
|
case "move-list-board": {
|
|
if (target.kind !== "item") {
|
|
return;
|
|
}
|
|
|
|
openMoveSheet({
|
|
kind: "item",
|
|
id: target.id,
|
|
label: target.label,
|
|
});
|
|
return;
|
|
}
|
|
case "delete-doc":
|
|
case "delete-board":
|
|
case "delete-list-board": {
|
|
if (target.kind !== "item") {
|
|
return;
|
|
}
|
|
|
|
openConfirmDialog({
|
|
title: "Delete item?",
|
|
message: `"${target.label}" will be removed from the project tree.`,
|
|
confirmLabel: "Delete",
|
|
tone: "danger",
|
|
onConfirm: () => {
|
|
void deleteItem(target.id);
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
default:
|
|
return;
|
|
}
|
|
};
|
|
|
|
const workspaceLongPress = createLongPressGesture({
|
|
onLongPress: openWorkspaceActionSheet,
|
|
});
|
|
|
|
return (
|
|
<Show when={props.open}>
|
|
<div class={styles.browserLayer} data-ui="mobile-workspace-browser">
|
|
<section class={styles.sheet} aria-label="Mobile workspace browser" data-slot="mobile-workspace-sheet">
|
|
<header class={styles.sheetHeader} data-slot="mobile-workspace-header">
|
|
<div
|
|
class={styles.brandBlock}
|
|
data-slot="mobile-workspace-brand"
|
|
onContextMenu={(event): void => {
|
|
event.preventDefault();
|
|
openWorkspaceActionSheet();
|
|
}}
|
|
{...workspaceLongPress}
|
|
>
|
|
{/* Long-pressing the browser header exposes workspace-level actions on mobile. */}
|
|
<span class={styles.brandEyebrow}>Moku Work</span>
|
|
<strong class={styles.brandTitle}>{appShellData.activeProject().name}</strong>
|
|
<span class={styles.brandContext}>{appShellData.activeServer().name}</span>
|
|
</div>
|
|
|
|
<div class={styles.headerActions} data-slot="mobile-workspace-header-actions">
|
|
<button
|
|
class={styles.createButton}
|
|
type="button"
|
|
aria-label="Create"
|
|
data-slot="mobile-workspace-create"
|
|
onClick={openWorkspaceActionSheet}
|
|
>
|
|
<Plus size={16} strokeWidth={2.25} />
|
|
<span>Create</span>
|
|
</button>
|
|
|
|
<button class={styles.closeButton} type="button" aria-label="Close workspace browser" data-slot="mobile-workspace-close" onClick={props.onClose}>
|
|
<X size={18} strokeWidth={2} />
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
<div class={styles.sheetBody} data-slot="mobile-workspace-body">
|
|
<section class={styles.sectionBlock} data-slot="mobile-workspace-section" data-section-id="workspace">
|
|
<span class={styles.sectionLabel}>Workspace</span>
|
|
<ul class={styles.treeList} data-slot="mobile-workspace-list" data-section-id="workspace">
|
|
<For each={workspaceStaticItems}>
|
|
{(item): JSX.Element => <WorkspaceStaticRow item={item} onOpenActionSheet={openActionSheet} />}
|
|
</For>
|
|
</ul>
|
|
</section>
|
|
|
|
<section class={styles.sectionBlock} data-slot="mobile-workspace-section" data-section-id="items">
|
|
<span class={styles.sectionLabel}>Items</span>
|
|
<ul class={styles.treeList} data-slot="mobile-workspace-list" data-section-id="items">
|
|
<WorkspaceTreeBranch nodes={workspaceTreeNodes()} onOpenActionSheet={openActionSheet} />
|
|
</ul>
|
|
</section>
|
|
</div>
|
|
</section>
|
|
|
|
<WorkspaceMobileActionSheet target={actionSheetTarget()} onClose={closeActionSheet} onSelect={handleActionSelect} />
|
|
|
|
<Show when={moveTarget()}>
|
|
{(state): JSX.Element => (
|
|
<Portal>
|
|
<div class={styles.moveSheetLayer} data-ui="mobile-workspace-move-sheet">
|
|
<button class={styles.dialogBackdrop} type="button" aria-label="Close move sheet" onClick={closeMoveSheet} />
|
|
<section class={styles.moveSheet} aria-label={`Move ${state().label}`}>
|
|
<div class={styles.moveSheetHandle} aria-hidden="true" />
|
|
<div class={styles.moveSheetHeader}>
|
|
<div class={styles.moveSheetHeaderCopy}>
|
|
<span class={styles.moveSheetEyebrow}>Move {state().kind}</span>
|
|
<strong class={styles.moveSheetTitle}>{state().label}</strong>
|
|
<p class={styles.moveSheetMessage}>Choose a new location in the project tree.</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class={styles.moveSection}>
|
|
<span class={styles.moveSectionLabel}>Destination</span>
|
|
<div class={styles.moveDestinationList}>
|
|
<For each={moveDestinations()}>
|
|
{(destination): JSX.Element => (
|
|
<button
|
|
type="button"
|
|
class={styles.moveDestinationButton}
|
|
style={{ "--move-depth": `${destination.depth}` }}
|
|
onClick={(): void => handleMoveDestinationSelect(destination.id)}
|
|
>
|
|
<span class={styles.moveDestinationLabel}>{destination.label}</span>
|
|
<Show when={destination.meta}>
|
|
<span class={styles.moveDestinationMeta}>{destination.meta}</span>
|
|
</Show>
|
|
</button>
|
|
)}
|
|
</For>
|
|
</div>
|
|
</div>
|
|
|
|
<div class={styles.moveSheetFooter}>
|
|
<button class={styles.dialogSecondaryButton} type="button" onClick={closeMoveSheet}>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</Portal>
|
|
)}
|
|
</Show>
|
|
|
|
<Show when={dialogState()}>
|
|
{(state): JSX.Element => (
|
|
<Portal>
|
|
<div class={styles.dialogLayer} data-ui="mobile-workspace-dialog">
|
|
<button class={styles.dialogBackdrop} type="button" aria-label="Close dialog" onClick={closeDialog} />
|
|
<section class={styles.dialogCard} aria-label={state().title}>
|
|
<div class={styles.dialogCopy}>
|
|
<strong class={styles.dialogTitle}>{state().title}</strong>
|
|
<p class={styles.dialogMessage}>{state().message}</p>
|
|
</div>
|
|
|
|
<Show when={state().kind === "text"}>
|
|
<input
|
|
class={styles.dialogInput}
|
|
type="text"
|
|
value={dialogValue()}
|
|
onInput={(event): void => {
|
|
setDialogValue(event.currentTarget.value);
|
|
}}
|
|
onKeyDown={(event): void => {
|
|
if (event.key === "Enter") {
|
|
event.preventDefault();
|
|
submitDialog();
|
|
}
|
|
}}
|
|
autofocus
|
|
/>
|
|
</Show>
|
|
|
|
<div class={styles.dialogActions}>
|
|
<button class={styles.dialogSecondaryButton} type="button" onClick={closeDialog}>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
classList={{
|
|
[styles.dialogPrimaryButton]: true,
|
|
[styles.dialogDangerButton]: isDangerDialogState(state()),
|
|
}}
|
|
type="button"
|
|
onClick={submitDialog}
|
|
>
|
|
{state().confirmLabel}
|
|
</button>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</Portal>
|
|
)}
|
|
</Show>
|
|
</div>
|
|
</Show>
|
|
);
|
|
};
|