| <script lang="ts"> |
| import { getContext, onMount, onDestroy, tick } from "svelte"; |
| |
| import type { Editor } from "@graphite/editor"; |
| import { beginDraggingElement } from "@graphite/io-managers/drag"; |
| import { |
| defaultWidgetLayout, |
| patchWidgetLayout, |
| UpdateDocumentLayerDetails, |
| UpdateDocumentLayerStructureJs, |
| UpdateLayersPanelControlBarLeftLayout, |
| UpdateLayersPanelControlBarRightLayout, |
| UpdateLayersPanelBottomBarLayout, |
| } from "@graphite/messages"; |
| import type { DataBuffer, LayerPanelEntry } from "@graphite/messages"; |
| import type { NodeGraphState } from "@graphite/state-providers/node-graph"; |
| import { platformIsMac } from "@graphite/utility-functions/platform"; |
| import { extractPixelData } from "@graphite/utility-functions/rasterization"; |
| |
| import LayoutCol from "@graphite/components/layout/LayoutCol.svelte"; |
| import LayoutRow from "@graphite/components/layout/LayoutRow.svelte"; |
| import IconButton from "@graphite/components/widgets/buttons/IconButton.svelte"; |
| import IconLabel from "@graphite/components/widgets/labels/IconLabel.svelte"; |
| import Separator from "@graphite/components/widgets/labels/Separator.svelte"; |
| import WidgetLayout from "@graphite/components/widgets/WidgetLayout.svelte"; |
| |
| type LayerListingInfo = { |
| folderIndex: number; |
| bottomLayer: boolean; |
| editingName: boolean; |
| entry: LayerPanelEntry; |
| }; |
| |
| type DraggingData = { |
| select?: () => void; |
| insertParentId: bigint | undefined; |
| insertDepth: number; |
| insertIndex: number | undefined; |
| highlightFolder: boolean; |
| markerHeight: number; |
| }; |
| |
| const editor = getContext<Editor>("editor"); |
| const nodeGraph = getContext<NodeGraphState>("nodeGraph"); |
| |
| let list: LayoutCol | undefined; |
| |
| |
| let layerCache = new Map<string, LayerPanelEntry>(); |
| let layers: LayerListingInfo[] = []; |
| |
| |
| let draggable = true; |
| let draggingData: undefined | DraggingData = undefined; |
| let fakeHighlightOfNotYetSelectedLayerBeingDragged: undefined | bigint = undefined; |
| let dragInPanel = false; |
| |
| |
| let layerToClipUponClick: LayerListingInfo | undefined = undefined; |
| let layerToClipAltKeyPressed = false; |
| |
| |
| let layersPanelControlBarLeftLayout = defaultWidgetLayout(); |
| let layersPanelControlBarRightLayout = defaultWidgetLayout(); |
| let layersPanelBottomBarLayout = defaultWidgetLayout(); |
| |
| onMount(() => { |
| editor.subscriptions.subscribeJsMessage(UpdateLayersPanelControlBarLeftLayout, (updateLayersPanelControlBarLeftLayout) => { |
| patchWidgetLayout(layersPanelControlBarLeftLayout, updateLayersPanelControlBarLeftLayout); |
| layersPanelControlBarLeftLayout = layersPanelControlBarLeftLayout; |
| }); |
| |
| editor.subscriptions.subscribeJsMessage(UpdateLayersPanelControlBarRightLayout, (updateLayersPanelControlBarRightLayout) => { |
| patchWidgetLayout(layersPanelControlBarRightLayout, updateLayersPanelControlBarRightLayout); |
| layersPanelControlBarRightLayout = layersPanelControlBarRightLayout; |
| }); |
| |
| editor.subscriptions.subscribeJsMessage(UpdateLayersPanelBottomBarLayout, (updateLayersPanelBottomBarLayout) => { |
| patchWidgetLayout(layersPanelBottomBarLayout, updateLayersPanelBottomBarLayout); |
| layersPanelBottomBarLayout = layersPanelBottomBarLayout; |
| }); |
| |
| editor.subscriptions.subscribeJsMessage(UpdateDocumentLayerStructureJs, (updateDocumentLayerStructure) => { |
| const structure = newUpdateDocumentLayerStructure(updateDocumentLayerStructure.dataBuffer); |
| rebuildLayerHierarchy(structure); |
| }); |
| |
| editor.subscriptions.subscribeJsMessage(UpdateDocumentLayerDetails, (updateDocumentLayerDetails) => { |
| const targetLayer = updateDocumentLayerDetails.data; |
| const targetId = targetLayer.id; |
| |
| updateLayerInTree(targetId, targetLayer); |
| }); |
| |
| addEventListener("pointermove", clippingHover); |
| addEventListener("keydown", clippingKeyPress); |
| addEventListener("keyup", clippingKeyPress); |
| }); |
| |
| onDestroy(() => { |
| removeEventListener("pointermove", clippingHover); |
| removeEventListener("keydown", clippingKeyPress); |
| removeEventListener("keyup", clippingKeyPress); |
| }); |
| |
| type DocumentLayerStructure = { |
| layerId: bigint; |
| children: DocumentLayerStructure[]; |
| }; |
| |
| function newUpdateDocumentLayerStructure(dataBuffer: DataBuffer): DocumentLayerStructure { |
| const pointerNum = Number(dataBuffer.pointer); |
| const lengthNum = Number(dataBuffer.length); |
| |
| const wasmMemoryBuffer = editor.raw.buffer; |
| |
| // Decode the folder structure encoding |
| const encoding = new DataView(wasmMemoryBuffer, pointerNum, lengthNum); |
| |
| // The structure section indicates how to read through the upcoming layer list and assign depths to each layer |
| const structureSectionLength = Number(encoding.getBigUint64(0, true)); |
| const structureSectionMsbSigned = new DataView(wasmMemoryBuffer, pointerNum + 8, structureSectionLength * 8); |
| |
| // The layer IDs section lists each layer ID sequentially in the tree, as it will show up in the panel |
| const layerIdsSection = new DataView(wasmMemoryBuffer, pointerNum + 8 + structureSectionLength * 8); |
| |
| let layersEncountered = 0; |
| let currentFolder: DocumentLayerStructure = { layerId: BigInt(-1), children: [] }; |
| const currentFolderStack = [currentFolder]; |
| |
| for (let i = 0; i < structureSectionLength; i += 1) { |
| const msbSigned = structureSectionMsbSigned.getBigUint64(i * 8, true); |
| const msbMask = BigInt(1) << BigInt(64 - 1); |
| |
| // Set the MSB to 0 to clear the sign and then read the number as usual |
| const numberOfLayersAtThisDepth = msbSigned & ~msbMask; |
| |
| // Store child folders in the current folder (until we are interrupted by an indent) |
| for (let j = 0; j < numberOfLayersAtThisDepth; j += 1) { |
| const layerId = layerIdsSection.getBigUint64(layersEncountered * 8, true); |
| layersEncountered += 1; |
| |
| const childLayer: DocumentLayerStructure = { layerId, children: [] }; |
| currentFolder.children.push(childLayer); |
| } |
| |
| |
| const subsequentDirectionOfDepthChange = (msbSigned & msbMask) === BigInt(0); |
| |
| if (subsequentDirectionOfDepthChange) { |
| currentFolderStack.push(currentFolder); |
| currentFolder = currentFolder.children[currentFolder.children.length - 1]; |
| } |
| |
| else { |
| const popped = currentFolderStack.pop(); |
| if (!popped) throw Error("Too many negative indents in the folder structure"); |
| if (popped) currentFolder = popped; |
| } |
| } |
| |
| return currentFolder; |
| } |
| |
| function toggleNodeVisibilityLayerPanel(id: bigint) { |
| editor.handle.toggleNodeVisibilityLayerPanel(id); |
| } |
| |
| function toggleLayerLock(id: bigint) { |
| editor.handle.toggleLayerLock(id); |
| } |
| |
| function handleExpandArrowClickWithModifiers(e: MouseEvent, id: bigint) { |
| const accel = platformIsMac() ? e.metaKey : e.ctrlKey; |
| const collapseRecursive = e.altKey || accel; |
| editor.handle.toggleLayerExpansion(id, collapseRecursive); |
| e.stopPropagation(); |
| } |
| |
| async function onEditLayerName(listing: LayerListingInfo) { |
| if (listing.editingName) return; |
| |
| draggable = false; |
| listing.editingName = true; |
| layers = layers; |
| |
| await tick(); |
| |
| const query = list?.div?.()?.querySelector("[data-text-input]:not([disabled])"); |
| const textInput = (query instanceof HTMLInputElement && query) || undefined; |
| textInput?.select(); |
| } |
| |
| function onEditLayerNameChange(listing: LayerListingInfo, e: Event) { |
| // Eliminate duplicate events |
| if (!listing.editingName) return; |
| |
| draggable = true; |
| listing.editingName = false; |
| layers = layers; |
| |
| const name = (e.target instanceof HTMLInputElement && e.target.value) || ""; |
| editor.handle.setLayerName(listing.entry.id, name); |
| listing.entry.alias = name; |
| } |
| |
| async function onEditLayerNameDeselect(listing: LayerListingInfo) { |
| draggable = true; |
| listing.editingName = false; |
| layers = layers; |
| |
| // Set it back to the original name if the user didn't enter a new name |
| if (document.activeElement instanceof HTMLInputElement) document.activeElement.value = listing.entry.alias; |
| |
| // Deselect the text so it doesn't appear selected while the input field becomes disabled and styled to look like regular text |
| window.getSelection()?.removeAllRanges(); |
| } |
| |
| function selectLayerWithModifiers(e: MouseEvent, listing: LayerListingInfo) { |
| // Get the pressed state of the modifier keys |
| const [ctrl, meta, shift, alt] = [e.ctrlKey, e.metaKey, e.shiftKey, e.altKey]; |
| // Get the state of the platform's accel key and its opposite platform's accel key |
| const [accel, oppositeAccel] = platformIsMac() ? [meta, ctrl] : [ctrl, meta]; |
| |
| // Alt-clicking to make a clipping mask |
| if (layerToClipAltKeyPressed && layerToClipUponClick && layerToClipUponClick.entry.clippable) clipLayer(layerToClipUponClick); |
| // Select the layer only if the accel and/or shift keys are pressed |
| else if (!oppositeAccel && !alt) selectLayer(listing, accel, shift); |
| |
| e.stopPropagation(); |
| } |
| |
| function clipLayer(listing: LayerListingInfo) { |
| editor.handle.clipLayer(listing.entry.id); |
| } |
| |
| function clippingKeyPress(e: KeyboardEvent) { |
| layerToClipAltKeyPressed = e.altKey; |
| } |
| |
| function clippingHover(e: PointerEvent) { |
| // Don't do anything if the user is dragging to rearrange layers |
| if (dragInPanel) return; |
| |
| // Get the layer below the cursor |
| const target = (e.target instanceof HTMLElement && e.target.closest("[data-layer]")) || undefined; |
| if (!target) { |
| layerToClipUponClick = undefined; |
| return; |
| } |
| |
| |
| const DISTANCE = 6; |
| const distanceFromTop = e.clientY - target.getBoundingClientRect().top; |
| const distanceFromBottom = target.getBoundingClientRect().bottom - e.clientY; |
| |
| const nearTop = distanceFromTop < DISTANCE; |
| const nearBottom = distanceFromBottom < DISTANCE; |
| |
| |
| if (!nearTop && !nearBottom) { |
| layerToClipUponClick = undefined; |
| return; |
| } |
| |
| |
| const indexAttribute = target?.getAttribute("data-index") ?? undefined; |
| const index = indexAttribute ? Number(indexAttribute) : undefined; |
| const layer = index !== undefined && layers[nearTop ? index - 1 : index]; |
| if (!layer) return; |
| |
| |
| layerToClipUponClick = layer; |
| layerToClipAltKeyPressed = e.altKey; |
| } |
| |
| function selectLayer(listing: LayerListingInfo, accel: boolean, shift: boolean) { |
| // Don't select while we are entering text to rename the layer |
| if (listing.editingName) return; |
| |
| editor.handle.selectLayer(listing.entry.id, accel, shift); |
| } |
| |
| async function deselectAllLayers() { |
| editor.handle.deselectAllLayers(); |
| } |
| |
| function calculateDragIndex(tree: LayoutCol, clientY: number, select?: () => void): DraggingData { |
| const treeChildren = tree.div()?.children; |
| const treeOffset = tree.div()?.getBoundingClientRect().top; |
| |
| // Folder to insert into |
| let insertParentId: bigint | undefined = undefined; |
| let insertDepth = 0; |
| |
| // Insert index (starts at the end, essentially infinity) |
| let insertIndex = undefined; |
| |
| // Whether you are inserting into a folder and should show the folder outline |
| let highlightFolder = false; |
| |
| let markerHeight = 0; |
| const layerPanel = document.querySelector("[data-layer-panel]"); // Selects the element with the data-layer-panel attribute |
| if (layerPanel !== null && treeChildren !== undefined && treeOffset !== undefined) { |
| let layerPanelTop = layerPanel.getBoundingClientRect().top; |
| Array.from(treeChildren).forEach((treeChild) => { |
| const indexAttribute = treeChild.getAttribute("data-index"); |
| if (!indexAttribute) return; |
| const { folderIndex, entry: layer } = layers[parseInt(indexAttribute, 10)]; |
| |
| const rect = treeChild.getBoundingClientRect(); |
| if (rect.top > clientY || rect.bottom < clientY) { |
| return; |
| } |
| const pointerPercentage = (clientY - rect.top) / rect.height; |
| if (layer.childrenAllowed) { |
| if (pointerPercentage < 0.25) { |
| insertParentId = layer.parentId; |
| insertDepth = layer.depth - 1; |
| insertIndex = folderIndex; |
| markerHeight = rect.top - layerPanelTop; |
| } else if (pointerPercentage < 0.75 || (layer.childrenPresent && layer.expanded)) { |
| insertParentId = layer.id; |
| insertDepth = layer.depth; |
| insertIndex = 0; |
| highlightFolder = true; |
| } else { |
| insertParentId = layer.parentId; |
| insertDepth = layer.depth - 1; |
| insertIndex = folderIndex + 1; |
| markerHeight = rect.bottom - layerPanelTop; |
| } |
| } else { |
| if (pointerPercentage < 0.5) { |
| insertParentId = layer.parentId; |
| insertDepth = layer.depth - 1; |
| insertIndex = folderIndex; |
| markerHeight = rect.top - layerPanelTop; |
| } else { |
| insertParentId = layer.parentId; |
| insertDepth = layer.depth - 1; |
| insertIndex = folderIndex + 1; |
| markerHeight = rect.bottom - layerPanelTop; |
| } |
| } |
| }); |
| |
| let lastLayer = treeChildren[treeChildren.length - 1]; |
| if (lastLayer.getBoundingClientRect().bottom < clientY) { |
| const numberRootLayers = layers.filter((layer) => layer.entry.depth === 1).length; |
| insertParentId = undefined; |
| insertDepth = 0; |
| insertIndex = numberRootLayers; |
| markerHeight = lastLayer.getBoundingClientRect().bottom - layerPanelTop; |
| } |
| } |
| |
| return { |
| select, |
| insertParentId, |
| insertDepth, |
| insertIndex, |
| highlightFolder, |
| markerHeight, |
| }; |
| } |
| |
| async function dragStart(event: DragEvent, listing: LayerListingInfo) { |
| const layer = listing.entry; |
| dragInPanel = true; |
| if (!$nodeGraph.selected.includes(layer.id)) { |
| fakeHighlightOfNotYetSelectedLayerBeingDragged = layer.id; |
| } |
| const select = () => { |
| if (!$nodeGraph.selected.includes(layer.id)) selectLayer(listing, false, false); |
| }; |
| |
| const target = (event.target instanceof HTMLElement && event.target) || undefined; |
| const closest = target?.closest("[data-layer]") || undefined; |
| const draggingELement = (closest instanceof HTMLElement && closest) || undefined; |
| if (draggingELement) beginDraggingElement(draggingELement); |
| |
| |
| if (event.dataTransfer) { |
| event.dataTransfer.dropEffect = "move"; |
| event.dataTransfer.effectAllowed = "move"; |
| } |
| |
| if (list) draggingData = calculateDragIndex(list, event.clientY, select); |
| } |
| |
| function updateInsertLine(event: DragEvent) { |
| if (!draggable) return; |
| |
| // Stop the drag from being shown as cancelled |
| event.preventDefault(); |
| dragInPanel = true; |
| |
| if (list) draggingData = calculateDragIndex(list, event.clientY, draggingData?.select); |
| } |
| |
| function drop(e: DragEvent) { |
| if (!draggingData) return; |
| const { select, insertParentId, insertIndex } = draggingData; |
| |
| e.preventDefault(); |
| |
| if (e.dataTransfer) { |
| // Moving layers |
| if (e.dataTransfer.items.length === 0) { |
| if (draggable && dragInPanel) { |
| select?.(); |
| editor.handle.moveLayerInTree(insertParentId, insertIndex); |
| } |
| } |
| |
| else { |
| Array.from(e.dataTransfer.items).forEach(async (item) => { |
| const file = item.getAsFile(); |
| if (!file) return; |
| |
| if (file.type.includes("svg")) { |
| const svgData = await file.text(); |
| editor.handle.pasteSvg(file.name, svgData, undefined, undefined, insertParentId, insertIndex); |
| return; |
| } |
| |
| if (file.type.startsWith("image")) { |
| const imageData = await extractPixelData(file); |
| editor.handle.pasteImage(file.name, new Uint8Array(imageData.data), imageData.width, imageData.height, undefined, undefined, insertParentId, insertIndex); |
| return; |
| } |
| |
| |
| if (file.name.endsWith(".graphite")) { |
| const content = await file.text(); |
| editor.handle.openDocumentFile(file.name, content); |
| return; |
| } |
| }); |
| } |
| } |
| |
| draggingData = undefined; |
| fakeHighlightOfNotYetSelectedLayerBeingDragged = undefined; |
| dragInPanel = false; |
| } |
| |
| function rebuildLayerHierarchy(updateDocumentLayerStructure: DocumentLayerStructure) { |
| const layerWithNameBeingEdited = layers.find((layer: LayerListingInfo) => layer.editingName); |
| const layerIdWithNameBeingEdited = layerWithNameBeingEdited?.entry.id; |
| |
| // Clear the layer hierarchy before rebuilding it |
| layers = []; |
| |
| // Build the new layer hierarchy |
| const recurse = (folder: DocumentLayerStructure) => { |
| folder.children.forEach((item, index) => { |
| const mapping = layerCache.get(String(item.layerId)); |
| if (mapping) { |
| mapping.id = item.layerId; |
| layers.push({ |
| folderIndex: index, |
| bottomLayer: index === folder.children.length - 1, |
| entry: mapping, |
| editingName: layerIdWithNameBeingEdited === item.layerId, |
| }); |
| } |
| |
| |
| if (item.children.length >= 1) recurse(item); |
| }); |
| }; |
| recurse(updateDocumentLayerStructure); |
| layers = layers; |
| } |
| |
| function updateLayerInTree(targetId: bigint, targetLayer: LayerPanelEntry) { |
| layerCache.set(String(targetId), targetLayer); |
| |
| const layer = layers.find((layer: LayerListingInfo) => layer.entry.id === targetId); |
| if (layer) { |
| layer.entry = targetLayer; |
| layers = layers; |
| } |
| } |
| </script> |
| |
| <LayoutCol class="layers" on:dragleave={() => (dragInPanel = false)}> |
| <LayoutRow class="control-bar" scrollableX={true}> |
| <WidgetLayout layout={layersPanelControlBarLeftLayout} /> |
| <Separator /> |
| <WidgetLayout layout={layersPanelControlBarRightLayout} /> |
| </LayoutRow> |
| <LayoutRow class="list-area" scrollableY={true}> |
| <LayoutCol |
| class="list" |
| styles={{ cursor: layerToClipUponClick && layerToClipAltKeyPressed && layerToClipUponClick.entry.clippable ? "alias" : "auto" }} |
| data-layer-panel |
| bind:this={list} |
| on:click={() => deselectAllLayers()} |
| on:dragover={updateInsertLine} |
| on:dragend={drop} |
| on:drop={drop} |
| > |
| {#each layers as listing, index} |
| {@const selected = fakeHighlightOfNotYetSelectedLayerBeingDragged !== undefined ? fakeHighlightOfNotYetSelectedLayerBeingDragged === listing.entry.id : listing.entry.selected} |
| <LayoutRow |
| class="layer" |
| classes={{ |
| selected, |
| "ancestor-of-selected": listing.entry.ancestorOfSelected, |
| "descendant-of-selected": listing.entry.descendantOfSelected, |
| "selected-but-not-in-selected-network": selected && !listing.entry.inSelectedNetwork, |
| "insert-folder": (draggingData?.highlightFolder || false) && draggingData?.insertParentId === listing.entry.id, |
| }} |
| styles={{ "--layer-indent-levels": `${listing.entry.depth - 1}` }} |
| data-layer |
| data-index={index} |
| tooltip={listing.entry.tooltip} |
| {draggable} |
| on:dragstart={(e) => draggable && dragStart(e, listing)} |
| on:click={(e) => selectLayerWithModifiers(e, listing)} |
| > |
| {#if listing.entry.childrenAllowed} |
| <button |
| class="expand-arrow" |
| class:expanded={listing.entry.expanded} |
| disabled={!listing.entry.childrenPresent} |
| title={listing.entry.expanded |
| ? "Collapse (Click) / Collapse All (Alt Click)" |
| : `Expand (Click) / Expand All (Alt Click)${listing.entry.ancestorOfSelected ? "\n(A selected layer is contained within)" : ""}`} |
| on:click={(e) => handleExpandArrowClickWithModifiers(e, listing.entry.id)} |
| tabindex="0" |
| ></button> |
| {:else} |
| <div class="expand-arrow-none"></div> |
| {/if} |
| {#if listing.entry.clipped} |
| <IconLabel icon="Clipped" class="clipped-arrow" tooltip={"Clipping mask is active (Alt-click border to release)"} /> |
| {/if} |
| <div class="thumbnail"> |
| {#if $nodeGraph.thumbnails.has(listing.entry.id)} |
| {@html $nodeGraph.thumbnails.get(listing.entry.id)} |
| {/if} |
| </div> |
| {#if listing.entry.name === "Artboard"} |
| <IconLabel icon="Artboard" class={"layer-type-icon"} /> |
| {/if} |
| <LayoutRow class="layer-name" on:dblclick={() => onEditLayerName(listing)}> |
| <input |
| data-text-input |
| type="text" |
| value={listing.entry.alias} |
| placeholder={listing.entry.name} |
| disabled={!listing.editingName} |
| on:blur={() => onEditLayerNameDeselect(listing)} |
| on:keydown={(e) => e.key === "Escape" && onEditLayerNameDeselect(listing)} |
| on:keydown={(e) => e.key === "Enter" && onEditLayerNameChange(listing, e)} |
| on:change={(e) => onEditLayerNameChange(listing, e)} |
| /> |
| </LayoutRow> |
| {#if !listing.entry.unlocked || !listing.entry.parentsUnlocked} |
| <IconButton |
| class={"status-toggle"} |
| classes={{ inherited: !listing.entry.parentsUnlocked }} |
| action={(e) => (toggleLayerLock(listing.entry.id), e?.stopPropagation())} |
| size={24} |
| icon={listing.entry.unlocked ? "PadlockUnlocked" : "PadlockLocked"} |
| hoverIcon={listing.entry.unlocked ? "PadlockLocked" : "PadlockUnlocked"} |
| tooltip={(listing.entry.unlocked ? "Lock" : "Unlock") + (!listing.entry.parentsUnlocked ? "\n(A parent of this layer is locked and that status is being inherited)" : "")} |
| /> |
| {/if} |
| <IconButton |
| class={"status-toggle"} |
| classes={{ inherited: !listing.entry.parentsVisible }} |
| action={(e) => (toggleNodeVisibilityLayerPanel(listing.entry.id), e?.stopPropagation())} |
| size={24} |
| icon={listing.entry.visible ? "EyeVisible" : "EyeHidden"} |
| hoverIcon={listing.entry.visible ? "EyeHide" : "EyeShow"} |
| tooltip={(listing.entry.visible ? "Hide" : "Show") + (!listing.entry.parentsVisible ? "\n(A parent of this layer is hidden and that status is being inherited)" : "")} |
| /> |
| </LayoutRow> |
| {/each} |
| </LayoutCol> |
| {#if draggingData && !draggingData.highlightFolder && dragInPanel} |
| <div class="insert-mark" style:left={`${4 + draggingData.insertDepth * 16}px`} style:top={`${draggingData.markerHeight}px`} /> |
| {/if} |
| </LayoutRow> |
| <LayoutRow class="bottom-bar" scrollableX={true}> |
| <WidgetLayout layout={layersPanelBottomBarLayout} /> |
| </LayoutRow> |
| </LayoutCol> |
|
|
| <style lang="scss" global> |
| .layers { |
| // Control bar |
| .control-bar { |
| height: 32px; |
| flex: 0 0 auto; |
| margin: 0 4px; |
| border-bottom: 1px solid var(--color-2-mildblack); |
| justify-content: space-between; |
|
|
| .widget-span:first-child { |
| flex: 1 1 auto; |
| } |
| } |
|
|
| // Bottom bar |
| .bottom-bar { |
| height: 24px; |
| padding-top: 4px; |
| flex: 0 0 auto; |
| margin: 0 4px; |
| justify-content: flex-end; |
| border-top: 1px solid var(--color-2-mildblack); |
|
|
| .widget-span > * { |
| margin: 0; |
| } |
| } |
|
|
| // Layer hierarchy |
| .list-area { |
| position: relative; |
| margin-top: 4px; |
| // Combine with the bottom bar to avoid a double border |
| margin-bottom: -1px; |
|
|
| .layer { |
| flex: 0 0 auto; |
| align-items: center; |
| position: relative; |
| border-bottom: 1px solid var(--color-2-mildblack); |
| border-radius: 2px; |
| height: 32px; |
| margin: 0 4px; |
| padding-left: calc(var(--layer-indent-levels) * 16px); |
|
|
| // Dimming |
| &.selected { |
| background: var(--color-4-dimgray); |
| } |
|
|
| &.ancestor-of-selected .expand-arrow:not(.expanded) { |
| background-image: var(--inheritance-dots-background-6-lowergray); |
| } |
|
|
| &.descendant-of-selected { |
| background-image: var(--inheritance-dots-background-4-dimgray); |
| } |
|
|
| &.selected-but-not-in-selected-network { |
| background: rgba(var(--color-4-dimgray-rgb), 0.5); |
| } |
|
|
| &.insert-folder { |
| outline: 3px solid var(--color-e-nearwhite); |
| outline-offset: -3px; |
| } |
|
|
| .expand-arrow { |
| padding: 0; |
| margin: 0; |
| margin-right: 4px; |
| width: 16px; |
| height: 100%; |
| border: none; |
| position: relative; |
| background: none; |
| flex: 0 0 auto; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| border-radius: 2px; |
|
|
| &::after { |
| content: ""; |
| position: absolute; |
| width: 8px; |
| height: 8px; |
| background: var(--icon-expand-collapse-arrow); |
| } |
|
|
| &[disabled]::after { |
| background: var(--icon-expand-collapse-arrow-disabled); |
| } |
|
|
| &:hover:not([disabled]) { |
| background: var(--color-5-dullgray); |
|
|
| &::after { |
| background: var(--icon-expand-collapse-arrow-hover); |
| } |
| } |
|
|
| &.expanded::after { |
| transform: rotate(90deg); |
| } |
| } |
|
|
| .expand-arrow-none { |
| flex: 0 0 16px; |
| margin-right: 4px; |
| } |
|
|
| .clipped-arrow { |
| margin-left: 2px; |
| margin-right: 2px; |
| } |
|
|
| .thumbnail { |
| width: 36px; |
| height: 24px; |
| border-radius: 2px; |
| flex: 0 0 auto; |
| background-image: var(--color-transparent-checkered-background); |
| background-size: var(--color-transparent-checkered-background-size-mini); |
| background-position: var(--color-transparent-checkered-background-position-mini); |
| background-repeat: var(--color-transparent-checkered-background-repeat); |
|
|
| svg { |
| width: calc(100% - 4px); |
| height: calc(100% - 4px); |
| margin: 2px; |
| } |
| } |
|
|
| .layer-type-icon { |
| flex: 0 0 auto; |
| margin-left: 8px; |
| margin-right: -4px; |
| } |
|
|
| .layer-name { |
| flex: 1 1 100%; |
| margin: 0 8px; |
|
|
| input { |
| color: inherit; |
| background: none; |
| border: none; |
| outline: none; // Ok for input element |
| margin: 0; |
| padding: 0; |
| text-overflow: ellipsis; |
| white-space: nowrap; |
| overflow: hidden; |
| border-radius: 2px; |
| height: 24px; |
| width: 100%; |
|
|
| &:disabled { |
| -webkit-user-select: none; // Required as of Safari 15.0 (Graphite's minimum version) through the latest release |
| user-select: none; |
| // Workaround for `user-select: none` not working on <input> elements |
| pointer-events: none; |
| } |
| |
| &:focus { |
| background: var(--color-1-nearblack); |
| padding: 0 4px; |
| |
| &::placeholder { |
| opacity: 0.5; |
| } |
| } |
| |
| &::placeholder { |
| opacity: 1; |
| color: inherit; |
| } |
| } |
| } |
| |
| .status-toggle { |
| flex: 0 0 auto; |
| align-items: center; |
| height: 100%; |
| |
| &.inherited { |
| background-image: var(--inheritance-stripes-background); |
| } |
| |
| .icon-button { |
| height: 100%; |
| width: calc(24px + 2 * 4px); |
| } |
| } |
| } |
| |
| .insert-mark { |
| position: absolute; |
| left: 4px; |
| right: 4px; |
| background: var(--color-e-nearwhite); |
| margin-top: -3px; |
| height: 5px; |
| z-index: 1; |
| pointer-events: none; |
| } |
| } |
| } |
| </style> |
| |