Improve textarea and fix bugs (#5380)

* Delete Textarea-old.svelte

* Update PrDetailsModal.svelte

* update height calculation method

* remove `console.log`

* Delete autoHeight.ts

* Update CommitCard.svelte

* code review fixes

* improve textarea size detection

* Update CommitMessageInput.svelte

* simplify styles

* Update CommitMessageInput.svelte
This commit is contained in:
Pavel Laptev 2024-10-31 14:08:04 +01:00 committed by GitHub
parent f8d9434573
commit 359d8d5b51
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 109 additions and 405 deletions

View File

@ -150,7 +150,6 @@
<Modal bind:this={commitMessageModal} width="small" onSubmit={submitCommitMessageModal}>
{#snippet children(_, close)}
<CommitMessageInput
focusOnMount
bind:commitMessage={description}
bind:valid={commitMessageValid}
isExpanded={true}

View File

@ -19,16 +19,14 @@
import { getContext, getContextStore } from '@gitbutler/shared/context';
import Checkbox from '@gitbutler/ui/Checkbox.svelte';
import Icon from '@gitbutler/ui/Icon.svelte';
import Textarea from '@gitbutler/ui/Textarea.svelte';
import Tooltip from '@gitbutler/ui/Tooltip.svelte';
import { autoHeight } from '@gitbutler/ui/utils/autoHeight';
import { resizeObserver } from '@gitbutler/ui/utils/resizeObserver';
import { isWhiteSpaceString } from '@gitbutler/ui/utils/string';
import { createEventDispatcher, onMount, tick } from 'svelte';
import { fly } from 'svelte/transition';
export let isExpanded: boolean;
export let commitMessage: string;
export let focusOnMount: boolean = false;
export let valid: boolean = false;
export let commit: (() => void) | undefined = undefined;
export let cancel: () => void;
@ -60,15 +58,6 @@
return `${title}\n\n${description}`;
}
function focusTextAreaOnMount(el: HTMLTextAreaElement) {
if (focusOnMount) el.focus();
}
function updateFieldsHeight() {
if (titleTextArea) autoHeight(titleTextArea);
if (descriptionTextArea) autoHeight(descriptionTextArea);
}
async function generateCommitMessage(files: LocalFile[]) {
const hunks = files.flatMap((f) =>
f.hunks.filter((h) => $selectedOwnership.isSelected(f.id, h.id))
@ -111,11 +100,6 @@
}
aiLoading = false;
// set timeout to update the height of the textareas
setTimeout(() => {
updateFieldsHeight();
}, 0);
}
onMount(async () => {
@ -139,7 +123,6 @@
titleTextArea.focus();
titleTextArea.selectionStart = titleTextArea.textLength;
}
autoHeight(e.currentTarget);
return;
}
@ -179,7 +162,6 @@
if (descriptionTextArea) {
descriptionTextArea.focus();
descriptionTextArea.setSelectionRange(0, 0);
autoHeight(descriptionTextArea);
}
});
}
@ -194,40 +176,45 @@
</script>
{#if isExpanded}
<div class="commit-box__textarea-wrapper text-input" use:resizeObserver={updateFieldsHeight}>
<textarea
<div class="commit-box__textarea-wrapper text-input">
<Textarea
value={title}
unstyled
placeholder="Commit summary"
disabled={aiLoading}
class="text-13 text-body text-semibold commit-box__textarea commit-box__textarea__title"
fontSize={13}
padding={{ top: 12, right: 12, bottom: 0, left: 12 }}
fontWeight="semibold"
spellcheck="false"
rows="1"
bind:this={titleTextArea}
use:focusTextAreaOnMount
on:focus={(e) => autoHeight(e.currentTarget)}
on:input={(e) => {
commitMessage = concatMessage(e.currentTarget.value, description);
autoHeight(e.currentTarget);
minRows={1}
maxRows={10}
bind:textBoxEl={titleTextArea}
autofocus
oninput={(e: InputEvent & { currentTarget: HTMLTextAreaElement }) => {
const target = e.currentTarget;
commitMessage = concatMessage(target.value, description);
}}
on:keydown={handleSummaryKeyDown}
></textarea>
onkeydown={handleSummaryKeyDown}
/>
{#if title.length > 0 || description}
<textarea
<Textarea
value={description}
disabled={aiLoading}
unstyled
placeholder="Commit description (optional)"
class="text-13 text-body commit-box__textarea commit-box__textarea__description"
disabled={aiLoading}
fontSize={13}
padding={{ top: 0, right: 12, bottom: 0, left: 12 }}
spellcheck="false"
rows="1"
bind:this={descriptionTextArea}
on:focus={(e) => autoHeight(e.currentTarget)}
on:input={(e) => {
commitMessage = concatMessage(title, e.currentTarget.value);
autoHeight(e.currentTarget);
minRows={1}
maxRows={30}
bind:textBoxEl={descriptionTextArea}
oninput={(e: InputEvent & { currentTarget: HTMLTextAreaElement }) => {
const target = e.currentTarget;
commitMessage = concatMessage(title, target.value);
}}
on:keydown={handleDescriptionKeyDown}
></textarea>
onkeydown={handleDescriptionKeyDown}
/>
{/if}
{#if title.length > 50}
@ -310,24 +297,6 @@
}
}
.commit-box__textarea {
overflow: hidden;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 16px;
background: none;
resize: none;
&:focus {
outline: none;
}
&::placeholder {
color: oklch(from var(--clr-scale-ntrl-30) l c h / 0.4);
}
}
.commit-box__textarea-tooltip {
position: absolute;
bottom: 12px;
@ -345,15 +314,6 @@
color: var(--clr-scale-ntrl-50);
}
.commit-box__textarea__title {
min-height: 31px;
padding: 12px 12px 0 12px;
}
.commit-box__textarea__description {
padding: 0 12px 0 12px;
}
.commit-box__texarea-actions {
position: absolute;
right: 12px;

View File

@ -160,7 +160,6 @@
<Modal bind:this={commitMessageModal} width="small" onSubmit={submitCommitMessageModal}>
{#snippet children(_, close)}
<CommitMessageInput
focusOnMount
bind:commitMessage={description}
bind:valid={commitMessageValid}
isExpanded={true}

View File

@ -390,7 +390,7 @@
autofocus
padding={{ top: 12, right: 12, bottom: 12, left: 12 }}
placeholder="Add description…"
onchange={(e: InputEvent) => {
oninput={(e: InputEvent) => {
const target = e.currentTarget as HTMLTextAreaElement;
inputBody = target.value;
}}

View File

@ -1,253 +0,0 @@
<script lang="ts" module>
import { clickOutside } from './utils/clickOutside';
export interface Props {
id?: string;
textBoxEl?: HTMLDivElement;
label?: string;
value?: string;
placeholder?: string;
disabled?: boolean;
fontSize?: number;
minRows?: number;
maxRows?: number;
autofocus?: boolean;
spellcheck?: boolean;
autoComplete?: string;
class?: string;
flex?: string;
padding?: {
top: number;
right: number;
bottom: number;
left: number;
};
borderless?: boolean;
borderTop?: boolean;
borderRight?: boolean;
borderBottom?: boolean;
borderLeft?: boolean;
unstyled?: boolean;
oninput?: (e: Event & { currentTarget: EventTarget & HTMLTextAreaElement }) => void;
onfocus?: (
this: void,
e: FocusEvent & { currentTarget: EventTarget & HTMLTextAreaElement }
) => void;
onblur?: (
this: void,
e: FocusEvent & { currentTarget: EventTarget & HTMLTextAreaElement }
) => void;
onkeydown?: (e: KeyboardEvent & { currentTarget: EventTarget & HTMLTextAreaElement }) => void;
}
</script>
<script lang="ts">
import { pxToRem } from '$lib/utils/pxToRem';
let {
id,
textBoxEl = $bindable(),
label,
value = $bindable(),
placeholder,
disabled,
fontSize = 13,
minRows = 1,
maxRows = 100,
autofocus,
class: className = '',
flex,
padding = { top: 12, right: 12, bottom: 12, left: 12 },
borderless,
borderTop = true,
borderRight = true,
borderBottom = true,
borderLeft = true,
unstyled,
oninput,
onfocus,
onblur,
onkeydown
}: Props = $props();
let isEmpty = $state(value === '');
function getSelectionRange() {
const selection = window.getSelection();
if (selection) {
const range = selection.getRangeAt(0);
return range;
}
}
$effect(() => {
if (autofocus) {
textBoxEl?.focus();
}
});
$effect(() => {
if (textBoxEl) {
if (!disabled) {
textBoxEl.setAttribute('contenteditable', 'plaintext-only');
} else {
textBoxEl.removeAttribute('contenteditable');
}
}
});
$effect(() => {
if (value === ' ' || value === '') {
isEmpty = true;
} else {
isEmpty = false;
}
});
</script>
<div
class="textarea-container"
style:--placeholder-text={`"${placeholder && placeholder !== '' ? placeholder : 'Type here...'}"`}
style:--font-size={pxToRem(fontSize)}
style:--min-rows={minRows}
style:--max-rows={maxRows}
style:--padding-top={pxToRem(padding.top)}
style:--padding-right={pxToRem(padding.right)}
style:--padding-bottom={pxToRem(padding.bottom)}
style:--padding-left={pxToRem(padding.left)}
style:--line-height-ratio={1.6}
style:flex
>
{#if label}
<label class="textarea-label text-13 text-semibold" for={id}>
{label}
</label>
{/if}
<div
bind:this={textBoxEl}
use:clickOutside={{ handler: () => textBoxEl?.blur() }}
{id}
role="textbox"
aria-multiline="true"
tabindex={disabled ? -1 : 0}
contenteditable="plaintext-only"
onfocus={(e: Event) => {
if (e.currentTarget) {
onfocus?.(e as FocusEvent & { currentTarget: EventTarget & HTMLTextAreaElement });
}
}}
onblur={(e: Event) => {
if (e.currentTarget) {
onblur?.(e as FocusEvent & { currentTarget: EventTarget & HTMLTextAreaElement });
}
}}
oninput={(e: Event) => {
const innerText = (e.target as HTMLDivElement).innerText;
const eventMock = { currentTarget: { value: innerText } } as Event & {
currentTarget: EventTarget & HTMLTextAreaElement;
};
isEmpty = innerText === '';
oninput?.(eventMock);
}}
onkeydown={(e: KeyboardEvent) => {
const selection = getSelectionRange();
const eventMock = {
key: e.key,
code: e.code,
altKey: e.altKey,
metaKey: e.metaKey,
ctrlKey: e.ctrlKey,
shiftKey: e.shiftKey,
location: e.location,
currentTarget: {
value: (e.currentTarget as HTMLDivElement).innerText,
selectionStart: selection?.startOffset,
selectionEnd: selection?.endOffset
}
} as unknown as KeyboardEvent & { currentTarget: EventTarget & HTMLTextAreaElement };
onkeydown?.(eventMock);
}}
class="textarea scrollbar {className}"
class:disabled
class:text-input={!unstyled}
class:textarea-unstyled={unstyled}
class:textarea-placeholder={isEmpty}
style:border-top-width={borderTop && !borderless ? '1px' : '0'}
style:border-right-width={borderRight && !borderless ? '1px' : '0'}
style:border-bottom-width={borderBottom && !borderless ? '1px' : '0'}
style:border-left-width={borderLeft && !borderless ? '1px' : '0'}
style:border-top-right-radius={!borderTop || !borderRight ? '0' : undefined}
style:border-top-left-radius={!borderTop || !borderLeft ? '0' : undefined}
style:border-bottom-right-radius={!borderBottom || !borderRight ? '0' : undefined}
style:border-bottom-left-radius={!borderBottom || !borderLeft ? '0' : undefined}
>
{value}
</div>
</div>
<style lang="postcss">
.textarea-container {
display: flex;
flex-direction: column;
gap: 6px;
overflow-x: hidden;
}
@layer components {
.textarea-unstyled {
outline: none;
border: none;
}
}
.textarea {
font-family: var(--base-font-family);
line-height: var(--body-line-height);
font-weight: var(--base-font-weight);
white-space: pre-wrap;
cursor: text;
resize: none;
width: 100%;
font-size: var(--font-size);
min-height: calc(
var(--font-size) * var(--line-height-ratio) * var(--min-rows) + var(--padding-top) +
var(--padding-bottom)
);
max-height: calc(
var(--font-size) * var(--line-height-ratio) * var(--max-rows) + var(--padding-top) +
var(--padding-bottom)
);
padding: var(--padding-top) var(--padding-right) var(--padding-bottom) var(--padding-left);
overflow-y: auto; /* Enable scrolling when max height is reached */
overflow-x: hidden;
word-wrap: break-word;
transition:
border-color var(--transition-fast),
background-color var(--transition-fast);
&.disabled {
cursor: default;
}
&.textarea-placeholder {
display: block;
white-space: pre-wrap;
&:before {
content: var(--placeholder-text);
color: var(--clr-text-3);
cursor: text;
pointer-events: none;
position: absolute;
}
}
}
.textarea-label {
color: var(--clr-text-2);
}
</style>

View File

@ -6,6 +6,7 @@
value?: string;
placeholder?: string;
disabled?: boolean;
fontWeight?: 'regular' | 'bold' | 'semibold';
fontSize?: number;
minRows?: number;
maxRows?: number;
@ -55,6 +56,7 @@
maxRows = 100,
autofocus,
class: className = '',
fontWeight = 'regular',
flex,
padding = { top: 12, right: 12, bottom: 12, left: 12 },
borderless,
@ -70,7 +72,19 @@
onkeydown
}: Props = $props();
let textBoxValue = $state(value);
let measureEl: HTMLPreElement | undefined = $state();
$effect(() => {
// mock textarea style
if (textBoxEl && measureEl) {
const textBoxElStyles = window.getComputedStyle(textBoxEl);
measureEl.style.fontFamily = textBoxElStyles.fontFamily;
measureEl.style.fontSize = textBoxElStyles.fontSize;
measureEl.style.fontWeight = textBoxElStyles.fontWeight;
measureEl.style.border = textBoxElStyles.border;
}
});
$effect(() => {
if (autofocus) {
@ -78,23 +92,17 @@
}
});
$effect(() => {
if (value !== undefined) {
textBoxValue = value;
}
});
const lineHeight = 1.6;
let maxHeight = fontSize * 1.5 * maxRows + padding.top + padding.bottom;
let minHeight = fontSize * 1.5 * minRows + padding.top + padding.bottom;
let maxHeight = $derived(fontSize * maxRows + padding.top + padding.bottom);
let minHeight = $derived(fontSize * minRows + padding.top + padding.bottom);
let measureElHeight = $state(0);
let textBoxElHeight = $state(0);
</script>
<div
class="textarea-container"
style:--placeholder-text={`"${placeholder && placeholder !== '' ? placeholder : 'Type here...'}"`}
style:--font-size={pxToRem(fontSize)}
style:--min-rows={minRows}
style:--max-rows={maxRows}
style:--padding-top={pxToRem(padding.top)}
@ -112,54 +120,53 @@
<pre
class="textarea-measure-el"
aria-hidden="true"
bind:clientHeight={measureElHeight}
style="min-height: {pxToRem(minHeight)}; max-height: {pxToRem(maxHeight)}">{textBoxValue +
'\n'}</pre>
<div class="textarea-wrapper">
<textarea
bind:this={textBoxEl}
name={id}
{id}
bind:clientHeight={textBoxElHeight}
class="textarea scrollbar {className}"
class:disabled
class:text-input={!unstyled}
class:textarea-unstyled={unstyled}
style:height={pxToRem(measureElHeight)}
class:hide-scrollbar={measureElHeight < maxHeight}
style:border-top-width={borderTop && !borderless ? '1px' : '0'}
style:border-right-width={borderRight && !borderless ? '1px' : '0'}
style:border-bottom-width={borderBottom && !borderless ? '1px' : '0'}
style:border-left-width={borderLeft && !borderless ? '1px' : '0'}
style:border-top-right-radius={!borderTop || !borderRight ? '0' : undefined}
style:border-top-left-radius={!borderTop || !borderLeft ? '0' : undefined}
style:border-bottom-right-radius={!borderBottom || !borderRight ? '0' : undefined}
style:border-bottom-left-radius={!borderBottom || !borderLeft ? '0' : undefined}
{placeholder}
{value}
{disabled}
oninput={(e: Event & { currentTarget: EventTarget & HTMLTextAreaElement }) => {
textBoxValue = e.currentTarget.value;
oninput?.(e);
}}
onchange={(e: Event & { currentTarget: EventTarget & HTMLTextAreaElement }) => {
textBoxValue = e.currentTarget.value;
onchange?.(e);
}}
{onblur}
{onkeydown}
{onfocus}
rows={minRows}
></textarea>
</div>
bind:this={measureEl}
bind:offsetHeight={measureElHeight}
style:line-height={lineHeight}
style:min-height={pxToRem(minHeight)}
style:max-height={pxToRem(maxHeight)}>{value + '\n'}</pre>
<textarea
bind:this={textBoxEl}
name={id}
{id}
class="textarea scrollbar {className} text-{fontWeight}"
class:disabled
class:text-input={!unstyled}
class:textarea-unstyled={unstyled}
class:hide-scrollbar={measureElHeight < maxHeight}
style:height={pxToRem(measureElHeight)}
style:font-size={pxToRem(fontSize)}
style:border-top-width={borderTop && !borderless ? '1px' : '0'}
style:border-right-width={borderRight && !borderless ? '1px' : '0'}
style:border-bottom-width={borderBottom && !borderless ? '1px' : '0'}
style:border-left-width={borderLeft && !borderless ? '1px' : '0'}
style:border-top-right-radius={!borderTop || !borderRight ? '0' : undefined}
style:border-top-left-radius={!borderTop || !borderLeft ? '0' : undefined}
style:border-bottom-right-radius={!borderBottom || !borderRight ? '0' : undefined}
style:border-bottom-left-radius={!borderBottom || !borderLeft ? '0' : undefined}
{placeholder}
bind:value
{disabled}
{oninput}
{onchange}
{onblur}
{onkeydown}
{onfocus}
rows={minRows}
></textarea>
</div>
<style lang="postcss">
.textarea-container {
position: relative;
display: flex;
flex-direction: column;
gap: 6px;
overflow-x: hidden;
/* hide scrollbar */
&::-webkit-scrollbar {
display: none;
}
}
@layer components {
@ -175,30 +182,32 @@
display: flex;
}
.textarea-measure-el,
.textarea {
padding: var(--padding-top) var(--padding-right) var(--padding-bottom) var(--padding-left);
line-height: var(--line-height-ratio);
width: 100%;
word-wrap: break-word;
white-space: pre-wrap;
}
.textarea-measure-el {
z-index: 1;
position: absolute;
background-color: rgba(0, 0, 0, 0.1);
pointer-events: none;
height: fit-content;
margin: 0;
pointer-events: none;
overflow: hidden;
visibility: hidden;
}
.textarea,
.textarea-measure-el {
.textarea {
font-family: var(--base-font-family);
line-height: var(--body-line-height);
font-weight: var(--base-font-weight);
white-space: pre-wrap;
cursor: text;
resize: none;
width: 100%;
font-size: var(--font-size);
padding: var(--padding-top) var(--padding-right) var(--padding-bottom) var(--padding-left);
overflow-y: auto; /* Enable scrolling when max height is reached */
overflow-x: hidden;
word-wrap: break-word;
transition:
border-color var(--transition-fast),
background-color var(--transition-fast);
@ -227,6 +236,10 @@
}
}
.text-regular {
font-weight: var(--base-font-weight);
}
.textarea-label {
color: var(--clr-text-2);
}

View File

@ -1,9 +0,0 @@
export function autoHeight(element: HTMLTextAreaElement) {
if (!element) return;
const elementBorder =
parseInt(getComputedStyle(element).borderTopWidth) +
parseInt(getComputedStyle(element).borderBottomWidth);
element.style.height = 'auto';
element.style.height = `${element.scrollHeight + elementBorder}px`;
}

View File

@ -7,13 +7,8 @@
let changableValue = $state(props.value);
function fillTheForm() {
changableValue = `## ☕️ Reasoning
## 🧢 Changesd
## 📌 Todos`;
console.log('fillTheForm');
changableValue = `## ☕️ Reasoning ## 🧢 Chang sdf sdf sdfsdf sdfsfsd ## 📌 Todos`;
}
function handleDescriptionKeyDown(e: KeyboardEvent & { currentTarget: HTMLTextAreaElement }) {
@ -34,7 +29,7 @@
<div class="wrapper">
<Textarea
label={props.label}
value={changableValue}
bind:value={changableValue}
placeholder={props.placeholder}
minRows={props.minRows}
maxRows={props.maxRows}