Autosized Component Browser Input (#11161)

Closes #11011

When the code in Component Browser input is longer than the initial width, the CB is extended.

[Screencast From 2024-09-24 14-13-51.webm](https://github.com/user-attachments/assets/ae9df4e2-dcc8-46aa-9977-32f470fd85fd)
This commit is contained in:
Adam Obuchowicz 2024-09-27 11:08:15 +02:00 committed by GitHub
parent 0f4fa42eea
commit 79d40820cc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 82 additions and 63 deletions

View File

@ -434,7 +434,8 @@ const handler = componentBrowserBindings.handler({
--radius-default: 20px;
--background-color: #eaeaea;
--doc-panel-bottom-clip: 4px;
width: 295px;
min-width: 295px;
width: min-content;
color: rgba(0, 0, 0, 0.6);
font-size: 11.5px;
display: flex;

View File

@ -1,11 +1,10 @@
<script setup lang="ts">
import SvgIcon from '@/components/SvgIcon.vue'
import { useEvent } from '@/composables/events'
import AutoSizedInput, { type Range } from '@/components/widgets/AutoSizedInput.vue'
import type { useNavigator } from '@/composables/navigator'
import type { Icon } from '@/util/iconName'
import { computed, ref, watch, type DeepReadonly } from 'vue'
type Range = { start: number; end: number }
import { ComponentExposed } from 'vue-component-type-helpers'
const content = defineModel<DeepReadonly<{ text: string; selection: Range | undefined }>>({
required: true,
@ -16,52 +15,25 @@ const props = defineProps<{
nodeColor: string
}>()
const inputField = ref<HTMLInputElement>()
const fieldText = ref<string>('')
const fieldSelection = ref<Range>()
const inputField = ref<ComponentExposed<typeof AutoSizedInput>>()
watch(content, ({ text: newText, selection: newPos }) => {
fieldText.value = newText
if (inputField.value == null) return
inputField.value.value = newText
// If boundaries didn't change, don't overwrite selection dir.
if (
inputField.value.selectionStart !== newPos?.start ||
inputField.value.selectionEnd !== newPos?.end
)
inputField.value.setSelectionRange(newPos?.start ?? null, newPos?.end ?? null)
const fieldContent = ref<{ text: string; selection: Range | undefined }>({
text: '',
selection: undefined,
})
watch(fieldText, readInputFieldSelection)
watch([fieldText, fieldSelection], ([newText, newSelection]) => {
content.value = {
text: newText,
selection: newSelection,
}
watch(content, (newContent) => {
fieldContent.value = newContent
})
function readInputFieldSelection() {
if (
inputField.value != null &&
inputField.value.selectionStart != null &&
inputField.value.selectionEnd != null
) {
fieldSelection.value = {
start: inputField.value.selectionStart,
end: inputField.value.selectionEnd,
watch(
[() => fieldContent.value.text, () => fieldContent.value.selection],
([newText, newSelection]) => {
content.value = {
text: newText,
selection: newSelection,
}
} else {
fieldSelection.value = undefined
}
}
// HTMLInputElement's same event is not supported in chrome yet. We just react for any
// selectionchange in the document and check if the input selection changed.
// BUT some operations like deleting does not emit 'selectionChange':
// https://bugs.chromium.org/p/chromium/issues/detail?id=725890
// Therefore we must also refresh selection after changing input.
useEvent(document, 'selectionchange', readInputFieldSelection)
},
)
defineExpose({
blur: () => inputField.value?.blur(),
@ -81,11 +53,13 @@ const rootStyle = computed(() => {
<div v-if="props.icon" class="iconPort">
<SvgIcon :name="props.icon" class="nodeIcon" />
</div>
<input
<AutoSizedInput
ref="inputField"
v-model="fieldText"
v-model="fieldContent.text"
v-model:selection="fieldContent.selection"
autocomplete="off"
class="inputField"
:acceptOnEnter="false"
@pointerdown.stop
@pointerup.stop
@click.stop
@ -105,20 +79,20 @@ const rootStyle = computed(() => {
border-radius: var(--radius-default);
background-color: var(--background-color);
padding: 0 var(--component-editor-padding);
width: 100%;
height: 40px;
display: flex;
flex-direction: row;
gap: 8px;
align-items: center;
}
.inputField {
border: none;
outline: none;
min-width: 0;
flex-grow: 1;
background: none;
font: inherit;
text-align: left;
flex-grow: 1;
}
.iconPort {
@ -151,8 +125,7 @@ const rootStyle = computed(() => {
.buttonPanel {
display: flex;
flex-direction: row;
flex-shrink: 0;
flex-grow: 0;
gap: 8px;
flex-grow: 0;
}
</style>

View File

@ -9,7 +9,7 @@ import { isIdentifier, type AstId, type Identifier } from '@/util/ast/abstract'
import { Err, Ok, type Result } from '@/util/data/result'
import { qnLastSegment, type QualifiedName } from '@/util/qualifiedName'
import { useToast } from '@/util/toast'
import { computed, proxyRefs, readonly, ref, type ComputedRef } from 'vue'
import { computed, proxyRefs, readonly, ref, watch, type ComputedRef } from 'vue'
/** Information how the component browser is used, needed for proper input initializing. */
export type Usage =

View File

@ -1,12 +1,23 @@
<script lang="ts">
export type Range = { start: number; end: number }
</script>
<script setup lang="ts">
import { useEvent } from '@/composables/events'
import { useAutoBlur } from '@/util/autoBlur'
import { getTextWidthByFont } from '@/util/measurement'
import { computed, ref, watch, type StyleValue } from 'vue'
const [model, modifiers] = defineModel<string>()
const props = defineProps<{
const [selection] = defineModel<Range | undefined>('selection')
const {
autoSelect = false,
placeholder = '',
acceptOnEnter = true,
} = defineProps<{
autoSelect?: boolean
placeholder?: string | undefined
acceptOnEnter?: boolean
}>()
const emit = defineEmits<{
input: [value: string | undefined]
@ -20,10 +31,15 @@ function onChange() {
emit('change', innerModel.value)
}
function onInput() {
readInputFieldSelection()
emit('input', innerModel.value)
}
const inputNode = ref<HTMLInputElement>()
useAutoBlur(inputNode)
function onFocus() {
if (props.autoSelect) {
if (autoSelect) {
inputNode.value?.select()
}
}
@ -38,15 +54,44 @@ const cssFont = computed(() => {
const ADDED_WIDTH_PX = 2
const getTextWidth = (text: string) => getTextWidthByFont(text, cssFont.value)
const inputWidth = computed(
() => getTextWidth(innerModel.value || (props.placeholder ?? '')) + ADDED_WIDTH_PX,
)
const inputWidth = computed(() => getTextWidth(innerModel.value || placeholder) + ADDED_WIDTH_PX)
const inputStyle = computed<StyleValue>(() => ({ width: `${inputWidth.value}px` }))
function onEnterDown() {
inputNode.value?.blur()
function onEnterDown(event: KeyboardEvent) {
if (acceptOnEnter) {
event.stopPropagation()
inputNode.value?.blur()
}
}
function readInputFieldSelection() {
if (inputNode.value?.selectionStart != null && inputNode.value.selectionEnd != null) {
selection.value = {
start: inputNode.value.selectionStart,
end: inputNode.value.selectionEnd,
}
} else {
selection.value = undefined
}
}
// HTMLInputElement's same event is not supported in chrome yet. We just react for any
// selectionchange in the document and check if the input selection changed.
// BUT some operations like deleting does not emit 'selectionChange':
// https://bugs.chromium.org/p/chromium/issues/detail?id=725890
// Therefore we must also refresh selection after changing input.
useEvent(document, 'selectionchange', readInputFieldSelection)
watch(selection, (newPos) => {
// If boundaries didn't change, don't overwrite selection dir.
if (
inputNode.value?.selectionStart !== newPos?.start ||
inputNode.value?.selectionEnd !== newPos?.end
) {
inputNode.value?.setSelectionRange(newPos?.start ?? null, newPos?.end ?? null)
}
})
defineExpose({
inputWidth,
getTextWidth,
@ -65,7 +110,7 @@ defineExpose({
ref="inputNode"
v-model="innerModel"
class="AutoSizedInput input"
:placeholder="placeholder ?? ''"
:placeholder="placeholder"
:style="inputStyle"
@pointerdown.stop
@click.stop
@ -73,8 +118,8 @@ defineExpose({
@keydown.delete.stop
@keydown.arrow-left.stop
@keydown.arrow-right.stop
@keydown.enter.stop="onEnterDown"
@input="emit('input', innerModel)"
@keydown.enter="onEnterDown"
@input="onInput"
@change="onChange"
@focus="onFocus"
/>