diff --git a/frontend/code/components/markdown.ts b/frontend/code/components/markdown.ts index af59c80c..64eed1cf 100644 --- a/frontend/code/components/markdown.ts +++ b/frontend/code/components/markdown.ts @@ -10,7 +10,7 @@ import { Language } from 'highlight.js'; import { LayoutContext } from '../layouting'; import { getElementHeight, getElementWidth } from '../layoutHelpers'; -import { firstDefined } from '../utils'; +import { copyToClipboard, firstDefined } from '../utils'; import { applyIcon } from '../designApplication'; export type MarkdownState = ComponentState & { @@ -116,13 +116,8 @@ function convertMarkdown( copyButton.addEventListener('click', (event) => { const codeToCopy = (codeBlockInner as HTMLElement).textContent ?? ''; - const textArea = document.createElement('textarea'); - textArea.value = codeToCopy; - document.body.appendChild(textArea); - textArea.select(); - document.execCommand('copy'); - document.body.removeChild(textArea); + copyToClipboard(codeToCopy); copyButton.title = 'Copied!'; applyIcon( diff --git a/frontend/code/components/scrollTarget.ts b/frontend/code/components/scrollTarget.ts index 71a0fc36..c338e63b 100644 --- a/frontend/code/components/scrollTarget.ts +++ b/frontend/code/components/scrollTarget.ts @@ -3,7 +3,9 @@ import { tryGetComponentByElement, } from '../componentManagement'; import { ComponentId } from '../dataModels'; +import { getTextDimensions } from '../layoutHelpers'; import { LayoutContext } from '../layouting'; +import { copyToClipboard } from '../utils'; import { ComponentBase, ComponentState } from './componentBase'; export type ScrollTargetState = ComponentState & { @@ -12,23 +14,31 @@ export type ScrollTargetState = ComponentState & { content?: ComponentId | null; copy_button_content?: ComponentId | null; copy_button_text?: string | null; + copy_button_spacing?: number; }; export class ScrollTargetComponent extends ComponentBase { state: Required; - linkElement: HTMLAnchorElement; + childContainerElement: HTMLElement; buttonContainerElement: HTMLElement; cachedButtonTextSize: [number, number]; createElement(): HTMLElement { - let element = document.createElement('div'); + let element = document.createElement('a'); element.classList.add('rio-scroll-target'); - this.linkElement = document.createElement('a'); - element.appendChild(this.linkElement); + this.childContainerElement = document.createElement('div'); + element.appendChild(this.childContainerElement); this.buttonContainerElement = document.createElement('div'); + this.buttonContainerElement.classList.add( + 'rio-scroll-target-url-copy-button' + ); + this.buttonContainerElement.addEventListener( + 'click', + this._onUrlCopyButtonClick.bind(this) + ); element.appendChild(this.buttonContainerElement); return element; @@ -41,26 +51,37 @@ export class ScrollTargetComponent extends ComponentBase { this.replaceOnlyChild( latentComponents, deltaState.content, - this.linkElement + this.childContainerElement ); if (deltaState.id !== undefined) { this.element.id = deltaState.id; } - if (deltaState.copy_button_content !== undefined) { + if ( + deltaState.copy_button_content !== undefined && + deltaState.copy_button_content !== null + ) { this._removeButtonChild(latentComponents); this.replaceOnlyChild( latentComponents, deltaState.copy_button_content, this.buttonContainerElement ); - } else if (deltaState.copy_button_text !== undefined) { + } else if ( + deltaState.copy_button_text !== undefined && + deltaState.copy_button_text !== null + ) { this._removeButtonChild(latentComponents); let textElement = document.createElement('span'); textElement.textContent = deltaState.copy_button_text; this.buttonContainerElement.appendChild(textElement); + + this.cachedButtonTextSize = getTextDimensions( + deltaState.copy_button_text, + 'text' + ); } } @@ -81,6 +102,13 @@ export class ScrollTargetComponent extends ComponentBase { } } + private _onUrlCopyButtonClick(): void { + let url = new URL(window.location.href); + url.hash = this.state.id; + + copyToClipboard(url.toString()); + } + updateNaturalWidth(ctx: LayoutContext): void { if (this.state.content === null) { this.naturalWidth = 0; @@ -95,12 +123,23 @@ export class ScrollTargetComponent extends ComponentBase { } else if (this.state.copy_button_text !== null) { this.naturalWidth += this.cachedButtonTextSize[0]; } + + // If both children exist, add the spacing + if ( + this.state.content !== null && + (this.state.copy_button_content !== null || + this.state.copy_button_text !== null) + ) { + this.naturalWidth += this.state.copy_button_spacing; + } } updateAllocatedWidth(ctx: LayoutContext): void { // The button component gets as much space as it requested, and the // other child gets all the rest - let remainingWidth = this.allocatedWidth; + let remainingWidth = + this.allocatedWidth - this.state.copy_button_spacing; + let buttonX = 0; if (this.state.copy_button_content !== null) { let buttonComponent = @@ -114,6 +153,16 @@ export class ScrollTargetComponent extends ComponentBase { if (this.state.content !== null) { componentsById[this.state.content]!.allocatedWidth = remainingWidth; + buttonX = remainingWidth + this.state.copy_button_spacing; + } + + if ( + this.state.copy_button_content !== null || + this.state.copy_button_text !== null + ) { + let childElement = this.buttonContainerElement + .firstElementChild as HTMLElement; + childElement.style.left = `${buttonX}rem`; } } diff --git a/frontend/code/utils.ts b/frontend/code/utils.ts index 6cd3e489..f616fb6e 100644 --- a/frontend/code/utils.ts +++ b/frontend/code/utils.ts @@ -94,3 +94,14 @@ export function firstDefined(...args: any[]): any { return undefined; } + +/// Copies the given text to the clipboard +export function copyToClipboard(text: string): void { + const textArea = document.createElement('textarea'); + textArea.value = text; + + document.body.appendChild(textArea); + textArea.select(); + document.execCommand('copy'); + document.body.removeChild(textArea); +} diff --git a/frontend/css/style.scss b/frontend/css/style.scss index 63080b08..9988de9f 100644 --- a/frontend/css/style.scss +++ b/frontend/css/style.scss @@ -1678,16 +1678,17 @@ textarea:not(:placeholder-shown) ~ .rio-input-box-label, .rio-scroll-target { pointer-events: auto; - display: flex; - flex-direction: row; - align-items: stretch; - // Hide the link-copy-button unless the cursor is hovering above - & > div { + & > .rio-scroll-target-url-copy-button { display: none; + + & > * { + position: absolute; + cursor: pointer; + } } - &:hover > div { + &:hover > .rio-scroll-target-url-copy-button { display: block; } } diff --git a/pyproject.toml b/pyproject.toml index 04a54179..c75e38eb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,6 +62,7 @@ classifiers = [ "Topic :: Internet :: WWW/HTTP :: WSGI :: Application", "Topic :: Software Development :: Libraries :: Python Modules", ] +version = "0.1.0" [project.optional-dependencies] window = [ diff --git a/rio/components/scroll_target.py b/rio/components/scroll_target.py index 18050d91..4a59ed41 100644 --- a/rio/components/scroll_target.py +++ b/rio/components/scroll_target.py @@ -50,6 +50,7 @@ class ScrollTarget(FundamentalComponent): content: rio.Component | None = None _: KW_ONLY copy_button_content: str | rio.Component | None = "ΒΆ" + copy_button_spacing: float = 0.5 def _custom_serialize(self) -> JsonDoc: button_content = self.copy_button_content