Make the tool shelf adapt to multiple columns and improve panel scrollbars

Closes #176
This commit is contained in:
Keavon Chambers
2023-12-06 02:10:40 -08:00
parent 9c83d054cf
commit 01499de8e7
17 changed files with 163 additions and 97 deletions
+1 -1
View File
@@ -281,7 +281,7 @@ impl LayoutHolder for ToolData {
.collect(); .collect();
Layout::WidgetLayout(WidgetLayout { Layout::WidgetLayout(WidgetLayout {
layout: vec![LayoutGroup::Column { widgets: tool_groups_layout }], layout: vec![LayoutGroup::Row { widgets: tool_groups_layout }],
}) })
} }
} }
+2 -9
View File
@@ -198,25 +198,18 @@
.layout-col { .layout-col {
.scrollable-x, .scrollable-x,
.scrollable-y { .scrollable-y {
overflow: hidden;
// Firefox (standardized in CSS, but less capable) // Firefox (standardized in CSS, but less capable)
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: var(--color-5-dullgray) transparent; scrollbar-color: var(--color-5-dullgray) transparent;
&:not(:hover) {
scrollbar-width: none;
}
// WebKit (only in Chromium/Safari but more capable) // WebKit (only in Chromium/Safari but more capable)
&::-webkit-scrollbar { &::-webkit-scrollbar {
width: calc(2px + 6px + 2px); width: calc(2px + 6px + 2px);
height: calc(2px + 6px + 2px); height: calc(2px + 6px + 2px);
} }
&:not(:hover)::-webkit-scrollbar {
width: 0;
height: 0;
}
&::-webkit-scrollbar-track { &::-webkit-scrollbar-track {
box-shadow: inset 0 0 0 1px var(--color-5-dullgray); box-shadow: inset 0 0 0 1px var(--color-5-dullgray);
border: 2px solid transparent; border: 2px solid transparent;
@@ -55,7 +55,7 @@
$: displayTail = open && type === "Popover"; $: displayTail = open && type === "Popover";
$: displayContainer = open || measuringOngoing; $: displayContainer = open || measuringOngoing;
$: extraClasses = Object.entries(classes) $: extraClasses = Object.entries(classes)
.flatMap((classAndState) => (classAndState[1] ? [classAndState[0]] : [])) .flatMap(([className, stateName]) => (stateName ? [className] : []))
.join(" "); .join(" ");
$: extraStyles = Object.entries(styles) $: extraStyles = Object.entries(styles)
.flatMap((styleAndValue) => (styleAndValue[1] !== undefined ? [`${styleAndValue[0]}: ${styleAndValue[1]};`] : [])) .flatMap((styleAndValue) => (styleAndValue[1] !== undefined ? [`${styleAndValue[0]}: ${styleAndValue[1]};`] : []))
@@ -12,7 +12,7 @@
let self: HTMLDivElement | undefined; let self: HTMLDivElement | undefined;
$: extraClasses = Object.entries(classes) $: extraClasses = Object.entries(classes)
.flatMap((classAndState) => (classAndState[1] ? [classAndState[0]] : [])) .flatMap(([className, stateName]) => (stateName ? [className] : []))
.join(" "); .join(" ");
$: extraStyles = Object.entries(styles) $: extraStyles = Object.entries(styles)
.flatMap((styleAndValue) => (styleAndValue[1] !== undefined ? [`${styleAndValue[0]}: ${styleAndValue[1]};`] : [])) .flatMap((styleAndValue) => (styleAndValue[1] !== undefined ? [`${styleAndValue[0]}: ${styleAndValue[1]};`] : []))
@@ -12,7 +12,7 @@
let self: HTMLDivElement | undefined; let self: HTMLDivElement | undefined;
$: extraClasses = Object.entries(classes) $: extraClasses = Object.entries(classes)
.flatMap((classAndState) => (classAndState[1] ? [classAndState[0]] : [])) .flatMap(([className, stateName]) => (stateName ? [className] : []))
.join(" "); .join(" ");
$: extraStyles = Object.entries(styles) $: extraStyles = Object.entries(styles)
.flatMap((styleAndValue) => (styleAndValue[1] !== undefined ? [`${styleAndValue[0]}: ${styleAndValue[1]};`] : [])) .flatMap((styleAndValue) => (styleAndValue[1] !== undefined ? [`${styleAndValue[0]}: ${styleAndValue[1]};`] : []))
+114 -40
View File
@@ -21,6 +21,7 @@
UpdateEyedropperSamplingState, UpdateEyedropperSamplingState,
UpdateMouseCursor, UpdateMouseCursor,
UpdateDocumentNodeRender, UpdateDocumentNodeRender,
isWidgetSpanRow,
} from "@graphite/wasm-communication/messages"; } from "@graphite/wasm-communication/messages";
import EyedropperPreview, { ZOOM_WINDOW_DIMENSIONS } from "@graphite/components/floating-menus/EyedropperPreview.svelte"; import EyedropperPreview, { ZOOM_WINDOW_DIMENSIONS } from "@graphite/components/floating-menus/EyedropperPreview.svelte";
@@ -80,6 +81,39 @@
$: canvasWidthCSS = canvasDimensionCSS(canvasSvgWidth); $: canvasWidthCSS = canvasDimensionCSS(canvasSvgWidth);
$: canvasHeightCSS = canvasDimensionCSS(canvasSvgHeight); $: canvasHeightCSS = canvasDimensionCSS(canvasSvgHeight);
$: toolShelfTotalToolsAndSeparators = ((layoutGroup) => {
if (!isWidgetSpanRow(layoutGroup)) return undefined;
let totalSeparators = 0;
let totalToolRowsFor1Columns = 0;
let totalToolRowsFor2Columns = 0;
let totalToolRowsFor3Columns = 0;
const tally = () => {
totalToolRowsFor1Columns += toolsInCurrentGroup;
totalToolRowsFor2Columns += Math.ceil(toolsInCurrentGroup / 2);
totalToolRowsFor3Columns += Math.ceil(toolsInCurrentGroup / 3);
toolsInCurrentGroup = 0;
};
let toolsInCurrentGroup = 0;
layoutGroup.rowWidgets.forEach((widget) => {
if (widget.props.kind === "Separator") {
totalSeparators += 1;
tally();
} else {
toolsInCurrentGroup += 1;
}
});
tally();
return {
totalSeparators,
totalToolRowsFor1Columns,
totalToolRowsFor2Columns,
totalToolRowsFor3Columns,
};
})($document.toolShelfLayout.layout[0]);
function pasteFile(e: DragEvent) { function pasteFile(e: DragEvent) {
const { dataTransfer } = e; const { dataTransfer } = e;
@@ -428,17 +462,26 @@
<WidgetLayout layout={$document.nodeGraphBarLayout} /> <WidgetLayout layout={$document.nodeGraphBarLayout} />
{/if} {/if}
</LayoutRow> </LayoutRow>
<LayoutRow class="shelf-and-table"> <LayoutRow
class="shelf-and-table"
styles={toolShelfTotalToolsAndSeparators && {
"--total-separators": toolShelfTotalToolsAndSeparators.totalSeparators,
"--total-tool-rows-for-1-columns": toolShelfTotalToolsAndSeparators.totalToolRowsFor1Columns,
"--total-tool-rows-for-2-columns": toolShelfTotalToolsAndSeparators.totalToolRowsFor2Columns,
"--total-tool-rows-for-3-columns": toolShelfTotalToolsAndSeparators.totalToolRowsFor3Columns,
}}
>
<LayoutCol class="shelf"> <LayoutCol class="shelf">
{#if !$document.graphViewOverlayOpen} {#if !$document.graphViewOverlayOpen}
<LayoutCol class="tools" scrollableY={true}> <LayoutCol class="tools" scrollableY={true}>
<WidgetLayout layout={$document.toolShelfLayout} /> <WidgetLayout layout={$document.toolShelfLayout} />
</LayoutCol> </LayoutCol>
{:else}
<LayoutRow class="spacer" />
{/if} {/if}
<LayoutCol class="spacer" />
<LayoutCol class="shelf-bottom-widgets"> <LayoutCol class="shelf-bottom-widgets">
<WidgetLayout layout={$document.graphViewOverlayButtonLayout} /> <WidgetLayout class={"graph-overlay-button-area"} layout={$document.graphViewOverlayButtonLayout} />
<WidgetLayout layout={$document.workingColorsLayout} /> <WidgetLayout class={"working-colors-button-area"} layout={$document.workingColorsLayout} />
</LayoutCol> </LayoutCol>
</LayoutCol> </LayoutCol>
<LayoutCol class="table"> <LayoutCol class="table">
@@ -523,71 +566,102 @@
min-width: 40px; min-width: 40px;
} }
&.for-graph .widget-layout { &.for-graph {
flex-direction: row;
flex-grow: 1;
justify-content: space-between; justify-content: space-between;
} }
} }
.shelf-and-table { .shelf-and-table {
// Enables usage of the `100cqh` unit to reference the height of this container element.
container-type: size;
// Be sure to recalculate this if the items below the tools (working colors and graph overlay buttons) change height in the future.
--height-of-elements-below-tools: 104px;
// Target height for the tools within the container above the lower elements.
--available-height: calc(100cqh - var(--height-of-elements-below-tools));
// The least height required to fit all the tools in 1 column and 2 columns, which the available space must exceed in order for the fewest number of columns to be used.
--1-col-required-height: calc(var(--total-tool-rows-for-1-columns) * 32px + var(--total-separators) * (1px + 8px * 2));
--2-col-required-height: calc(var(--total-tool-rows-for-2-columns) * 32px + var(--total-separators) * (1px + 8px * 2));
// Evaluates to 0px (if false) or 1px (if true). We multiply by 1000000 to force the result to be an integer 0 or 1 and not interpolate values in-between.
--needs-at-least-2-columns: calc(1px - clamp(0px, calc((var(--available-height) - Min(var(--available-height), var(--1-col-required-height))) * 1000000), 1px));
--needs-at-least-3-columns: calc(1px - clamp(0px, calc((var(--available-height) - Min(var(--available-height), var(--2-col-required-height))) * 1000000), 1px));
--columns: calc(1px + var(--needs-at-least-2-columns) + var(--needs-at-least-3-columns));
.shelf { .shelf {
width: 32px;
flex: 0 0 auto; flex: 0 0 auto;
justify-content: space-between;
.tools { .tools {
flex: 0 1 auto; flex: 0 1 auto;
.icon-button[title^="Coming Soon"] { // Firefox-specific workaround for this bug causing the scrollbar to cover up the toolbar instead of widening to accommodate the scrollbar:
opacity: 0.25; // <https://bugzilla.mozilla.org/show_bug.cgi?id=764076>
transition: opacity 0.25s; // <https://stackoverflow.com/questions/63278303/firefox-does-not-take-vertical-scrollbar-width-into-account-when-calculating-par>
// Remove this when the Firefox bug is fixed.
&:hover { @-moz-document url-prefix() {
opacity: 1; --available-height-plus-1: calc(var(--available-height) + 1px);
} --3-col-required-height: calc(var(--total-tool-rows-for-3-columns) * 32px + var(--total-separators) * (1px + 8px * 2));
--overflows-with-3-columns: calc(1px - clamp(0px, calc((var(--available-height-plus-1) - Min(var(--available-height-plus-1), var(--3-col-required-height))) * 1000000), 1px));
--firefox-scrollbar-width-space-occupied: 8; // Might change someday, or on different platforms, but this is the value in FF 120 on Windows
padding-right: calc(var(--firefox-scrollbar-width-space-occupied) * var(--overflows-with-3-columns));
} }
.icon-button:not(.active) { .widget-span {
.color-general { flex-wrap: wrap;
fill: var(--color-data-general); width: calc(var(--columns) * 32);
.icon-button {
margin: 0;
&[title^="Coming Soon"] {
opacity: 0.25;
transition: opacity 0.25s;
&:hover {
opacity: 1;
}
}
&:not(.active) {
.color-general {
fill: var(--color-data-general);
}
.color-vector {
fill: var(--color-data-vector);
}
.color-raster {
fill: var(--color-data-raster);
}
}
} }
.color-vector { .separator {
fill: var(--color-data-vector); min-height: 0;
}
.color-raster {
fill: var(--color-data-raster);
} }
} }
} }
.spacer {
flex: 1 0 auto;
min-height: 20px;
}
.shelf-bottom-widgets { .shelf-bottom-widgets {
flex: 0 0 auto; flex: 0 0 auto;
align-items: center;
.widget-layout:first-of-type { .graph-overlay-button-area {
height: auto; height: auto;
align-items: center; align-items: center;
} }
.widget-layout:last-of-type { .working-colors-button-area {
height: auto; height: auto;
margin: 0;
min-height: 0;
.widget-span.row { .working-colors-button {
min-height: 0; margin: 0;
}
.working-colors-button { .icon-button {
margin: 0; --widget-height: 0;
}
.icon-button {
--widget-height: 0;
}
} }
} }
} }
@@ -363,7 +363,6 @@
height: 32px; height: 32px;
flex: 0 0 auto; flex: 0 0 auto;
margin: 0 4px; margin: 0 4px;
align-items: center;
.widget-span { .widget-span {
width: 100%; width: 100%;
@@ -30,23 +30,23 @@
<LayoutRow class="options-bar"> <LayoutRow class="options-bar">
<WidgetLayout layout={propertiesOptionsLayout} /> <WidgetLayout layout={propertiesOptionsLayout} />
</LayoutRow> </LayoutRow>
<LayoutRow class="sections" scrollableY={true}> <LayoutCol class="sections" scrollableY={true}>
<WidgetLayout layout={propertiesSectionsLayout} /> <WidgetLayout layout={propertiesSectionsLayout} />
</LayoutRow> </LayoutCol>
</LayoutCol> </LayoutCol>
<style lang="scss" global> <style lang="scss" global>
.properties { .properties {
height: 100%; height: 100%;
flex: 1 1 100%;
.widget-layout {
flex: 1 1 100%;
margin: 0 4px;
}
.options-bar { .options-bar {
height: 32px; min-height: 32px;
flex: 0 0 auto; margin: 0 4px;
.widget-span {
width: 100%;
}
} }
.sections { .sections {
@@ -8,29 +8,16 @@
let className = ""; let className = "";
export { className as class }; export { className as class };
export let classes: Record<string, boolean> = {}; export let classes: Record<string, boolean> = {};
$: extraClasses = Object.entries(classes)
.flatMap((classAndState) => (classAndState[1] ? [classAndState[0]] : []))
.join(" ");
</script> </script>
<div class={`widget-layout ${className} ${extraClasses}`.trim()}> {#each layout.layout as layoutGroup}
{#each layout.layout as layoutGroup} {#if isWidgetSpanRow(layoutGroup) || isWidgetSpanColumn(layoutGroup)}
{#if isWidgetSpanRow(layoutGroup) || isWidgetSpanColumn(layoutGroup)} <WidgetSpan widgetData={layoutGroup} layoutTarget={layout.layoutTarget} class={className} {classes} />
<WidgetSpan widgetData={layoutGroup} layoutTarget={layout.layoutTarget} /> {:else if isWidgetSection(layoutGroup)}
{:else if isWidgetSection(layoutGroup)} <WidgetSection widgetData={layoutGroup} layoutTarget={layout.layoutTarget} class={className} {classes} />
<WidgetSection widgetData={layoutGroup} layoutTarget={layout.layoutTarget} /> {:else}
{:else} <span style="color: #d6536e">Error: The widget layout that belongs here has an invalid layout group type</span>
<span style="color: #d6536e">Error: The widget layout that belongs here has an invalid layout group type</span> {/if}
{/if} {/each}
{/each}
</div>
<style lang="scss" global> <style lang="scss" global></style>
.widget-layout {
height: 100%;
flex: 0 0 auto;
display: flex;
flex-direction: column;
}
</style>
@@ -9,11 +9,15 @@
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
export let layoutTarget: any; // TODO: Give type export let layoutTarget: any; // TODO: Give type
let className = "";
export { className as class };
export let classes: Record<string, boolean> = {};
let expanded = true; let expanded = true;
</script> </script>
<!-- TODO: Implement collapsable sections with properties system --> <!-- TODO: Implement collapsable sections with properties system -->
<LayoutCol class="widget-section"> <LayoutCol class={`widget-section ${className}`.trim()} {classes}>
<button class="header" class:expanded on:click|stopPropagation={() => (expanded = !expanded)} tabindex="0"> <button class="header" class:expanded on:click|stopPropagation={() => (expanded = !expanded)} tabindex="0">
<div class="expand-arrow" /> <div class="expand-arrow" />
<TextLabel bold={true}>{widgetData.name}</TextLabel> <TextLabel bold={true}>{widgetData.name}</TextLabel>
@@ -38,6 +42,7 @@
<style lang="scss" global> <style lang="scss" global>
.widget-section { .widget-section {
flex: 0 0 auto; flex: 0 0 auto;
margin: 0 4px;
.header { .header {
text-align: left; text-align: left;
@@ -38,6 +38,14 @@
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
export let layoutTarget: any; export let layoutTarget: any;
let className = "";
export { className as class };
export let classes: Record<string, boolean> = {};
$: extraClasses = Object.entries(classes)
.flatMap(([className, stateName]) => (stateName ? [className] : []))
.join(" ");
$: direction = watchDirection(widgetData); $: direction = watchDirection(widgetData);
$: widgets = watchWidgets(widgetData); $: widgets = watchWidgets(widgetData);
$: widgetsAndNextSiblingIsSuffix = watchWidgetsAndNextSiblingIsSuffix(widgets); $: widgetsAndNextSiblingIsSuffix = watchWidgetsAndNextSiblingIsSuffix(widgets);
@@ -81,7 +89,7 @@
<!-- TODO: Refactor this component to use `<svelte:component this={attributesObject} />` to avoid all the separate conditional components --> <!-- TODO: Refactor this component to use `<svelte:component this={attributesObject} />` to avoid all the separate conditional components -->
<!-- TODO: Also rename this component, and probably move the `widget-${direction}` wrapper to be part of `WidgetLayout.svelte` as part of its refactor --> <!-- TODO: Also rename this component, and probably move the `widget-${direction}` wrapper to be part of `WidgetLayout.svelte` as part of its refactor -->
<div class="widget-span" class:row={direction === "row"} class:column={direction === "column"}> <div class={`widget-span ${className} ${extraClasses}`.trim()} class:row={direction === "row"} class:column={direction === "column"}>
{#each widgetsAndNextSiblingIsSuffix as [component, nextIsSuffix], index} {#each widgetsAndNextSiblingIsSuffix as [component, nextIsSuffix], index}
{@const checkboxInput = narrowWidgetProps(component.props, "CheckboxInput")} {@const checkboxInput = narrowWidgetProps(component.props, "CheckboxInput")}
{#if checkboxInput} {#if checkboxInput}
@@ -17,7 +17,7 @@
export let classes: Record<string, boolean> = {}; export let classes: Record<string, boolean> = {};
$: extraClasses = Object.entries(classes) $: extraClasses = Object.entries(classes)
.flatMap((classAndState) => (classAndState[1] ? [classAndState[0]] : [])) .flatMap(([className, stateName]) => (stateName ? [className] : []))
.join(" "); .join(" ");
</script> </script>
@@ -14,7 +14,7 @@
return `size-${ICONS[icon].size}`; return `size-${ICONS[icon].size}`;
})(icon); })(icon);
$: extraClasses = Object.entries(classes) $: extraClasses = Object.entries(classes)
.flatMap((classAndState) => (classAndState[1] ? [classAndState[0]] : [])) .flatMap(([className, stateName]) => (stateName ? [className] : []))
.join(" "); .join(" ");
</script> </script>
@@ -11,7 +11,7 @@
export let tooltip: string | undefined = undefined; export let tooltip: string | undefined = undefined;
$: extraClasses = Object.entries(classes) $: extraClasses = Object.entries(classes)
.flatMap((classAndState) => (classAndState[1] ? [classAndState[0]] : [])) .flatMap(([className, stateName]) => (stateName ? [className] : []))
.join(" "); .join(" ");
</script> </script>
@@ -14,7 +14,7 @@
export let tooltip: string | undefined = undefined; export let tooltip: string | undefined = undefined;
$: extraClasses = Object.entries(classes) $: extraClasses = Object.entries(classes)
.flatMap((classAndState) => (classAndState[1] ? [classAndState[0]] : [])) .flatMap(([className, stateName]) => (stateName ? [className] : []))
.join(" "); .join(" ");
$: extraStyles = Object.entries(styles) $: extraStyles = Object.entries(styles)
.flatMap((styleAndValue) => (styleAndValue[1] !== undefined ? [`${styleAndValue[0]}: ${styleAndValue[1]};`] : [])) .flatMap((styleAndValue) => (styleAndValue[1] !== undefined ? [`${styleAndValue[0]}: ${styleAndValue[1]};`] : []))
@@ -178,7 +178,7 @@
.tab { .tab {
flex: 0 1 auto; flex: 0 1 auto;
height: 100%; height: 28px;
padding: 0 8px; padding: 0 8px;
align-items: center; align-items: center;
position: relative; position: relative;
@@ -216,7 +216,7 @@
white-space: nowrap; white-space: nowrap;
text-overflow: ellipsis; text-overflow: ellipsis;
// Height and line-height required because https://stackoverflow.com/a/21611191/775283 // Height and line-height required because https://stackoverflow.com/a/21611191/775283
height: 100%; height: 28px;
line-height: 28px; line-height: 28px;
} }
+3 -3
View File
@@ -1271,17 +1271,17 @@ export type LayoutGroup = WidgetSpanRow | WidgetSpanColumn | WidgetSection;
export type WidgetSpanColumn = { columnWidgets: Widget[] }; export type WidgetSpanColumn = { columnWidgets: Widget[] };
export function isWidgetSpanColumn(layoutColumn: LayoutGroup): layoutColumn is WidgetSpanColumn { export function isWidgetSpanColumn(layoutColumn: LayoutGroup): layoutColumn is WidgetSpanColumn {
return Boolean((layoutColumn as WidgetSpanColumn).columnWidgets); return Boolean((layoutColumn as WidgetSpanColumn)?.columnWidgets);
} }
export type WidgetSpanRow = { rowWidgets: Widget[] }; export type WidgetSpanRow = { rowWidgets: Widget[] };
export function isWidgetSpanRow(layoutRow: LayoutGroup): layoutRow is WidgetSpanRow { export function isWidgetSpanRow(layoutRow: LayoutGroup): layoutRow is WidgetSpanRow {
return Boolean((layoutRow as WidgetSpanRow).rowWidgets); return Boolean((layoutRow as WidgetSpanRow)?.rowWidgets);
} }
export type WidgetSection = { name: string; layout: LayoutGroup[] }; export type WidgetSection = { name: string; layout: LayoutGroup[] };
export function isWidgetSection(layoutRow: LayoutGroup): layoutRow is WidgetSection { export function isWidgetSection(layoutRow: LayoutGroup): layoutRow is WidgetSection {
return Boolean((layoutRow as WidgetSection).layout); return Boolean((layoutRow as WidgetSection)?.layout);
} }
// Unpacking rust types to more usable type in the frontend // Unpacking rust types to more usable type in the frontend