refactor(editor): Move editor-ui and design-system to frontend dir (no-changelog) (#13564)

This commit is contained in:
Alex Grozav
2025-02-28 14:28:30 +02:00
committed by GitHub
parent 684353436d
commit f5743176e5
1635 changed files with 805 additions and 1079 deletions

View File

@@ -0,0 +1,119 @@
<script setup lang="ts">
import { EditorState, type Extension } from '@codemirror/state';
import { EditorView } from '@codemirror/view';
import { useI18n } from '@/composables/useI18n';
import { highlighter } from '@/plugins/codemirror/resolvableHighlighter';
import type { Plaintext, Resolved, Segment } from '@/types/expressions';
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { forceParse } from '@/utils/forceParse';
interface ExpressionOutputProps {
segments: Segment[];
extensions?: Extension[];
}
const props = withDefaults(defineProps<ExpressionOutputProps>(), { extensions: () => [] });
const i18n = useI18n();
const editor = ref<EditorView | null>(null);
const root = ref<HTMLElement | null>(null);
const resolvedExpression = computed(() => {
if (props.segments.length === 0) {
return i18n.baseText('parameterInput.emptyString');
}
return props.segments.reduce(
(acc, segment) => {
// skip duplicate segments
if (acc.cursor >= segment.to) return acc;
acc.resolved += segment.kind === 'resolvable' ? String(segment.resolved) : segment.plaintext;
acc.cursor = segment.to;
return acc;
},
{ resolved: '', cursor: 0 },
).resolved;
});
const plaintextSegments = computed<Plaintext[]>(() => {
return props.segments.filter((s): s is Plaintext => s.kind === 'plaintext');
});
const resolvedSegments = computed<Resolved[]>(() => {
if (props.segments.length === 0) {
const emptyExpression = resolvedExpression.value;
const emptySegment: Resolved = {
from: 0,
to: emptyExpression.length,
kind: 'resolvable',
error: null,
resolvable: '',
resolved: emptyExpression,
state: 'pending',
};
return [emptySegment];
}
let cursor = 0;
return props.segments
.map((segment) => {
segment.from = cursor;
cursor +=
segment.kind === 'plaintext'
? segment.plaintext.length
: segment.resolved
? (segment.resolved as string | number | boolean).toString().length
: 0;
segment.to = cursor;
return segment;
})
.filter((segment): segment is Resolved => segment.kind === 'resolvable');
});
watch(
() => props.segments,
() => {
if (!editor.value) return;
editor.value.dispatch({
changes: { from: 0, to: editor.value.state.doc.length, insert: resolvedExpression.value },
});
highlighter.addColor(editor.value as EditorView, resolvedSegments.value);
highlighter.removeColor(editor.value as EditorView, plaintextSegments.value);
},
);
onMounted(() => {
editor.value = new EditorView({
parent: root.value as HTMLElement,
state: EditorState.create({
doc: resolvedExpression.value,
extensions: [
EditorState.readOnly.of(true),
EditorView.lineWrapping,
EditorView.domEventHandlers({ scroll: (_, view) => forceParse(view) }),
...props.extensions,
],
}),
});
highlighter.addColor(editor.value as EditorView, resolvedSegments.value);
highlighter.removeColor(editor.value as EditorView, plaintextSegments.value);
});
onBeforeUnmount(() => {
editor.value?.destroy();
});
defineExpose({ getValue: () => '=' + resolvedExpression.value });
</script>
<template>
<div ref="root" data-test-id="expression-output"></div>
</template>

View File

@@ -0,0 +1,137 @@
<script setup lang="ts">
import { history } from '@codemirror/commands';
import { type EditorState, Prec, type SelectionRange } from '@codemirror/state';
import { dropCursor, EditorView, keymap } from '@codemirror/view';
import { computed, ref, watch } from 'vue';
import { useExpressionEditor } from '@/composables/useExpressionEditor';
import { mappingDropCursor } from '@/plugins/codemirror/dragAndDrop';
import { editorKeymap } from '@/plugins/codemirror/keymap';
import { n8nAutocompletion, n8nLang } from '@/plugins/codemirror/n8nLang';
import { infoBoxTooltips } from '@/plugins/codemirror/tooltips/InfoBoxTooltip';
import type { Segment } from '@/types/expressions';
import type { IDataObject } from 'n8n-workflow';
import { inputTheme } from './theme';
import { onKeyStroke } from '@vueuse/core';
import { expressionCloseBrackets } from '@/plugins/codemirror/expressionCloseBrackets';
type Props = {
modelValue: string;
path: string;
rows?: number;
isReadOnly?: boolean;
additionalData?: IDataObject;
};
const props = withDefaults(defineProps<Props>(), {
rows: 5,
isReadOnly: false,
additionalData: () => ({}),
});
const emit = defineEmits<{
'update:model-value': [value: { value: string; segments: Segment[] }];
'update:selection': [value: { state: EditorState; selection: SelectionRange }];
focus: [];
}>();
const root = ref<HTMLElement>();
const extensions = computed(() => [
Prec.highest(keymap.of(editorKeymap)),
n8nLang(),
n8nAutocompletion(),
inputTheme({ isReadOnly: props.isReadOnly, rows: props.rows }),
history(),
mappingDropCursor(),
dropCursor(),
expressionCloseBrackets(),
EditorView.lineWrapping,
infoBoxTooltips(),
]);
const editorValue = computed(() => props.modelValue);
// Exit expression editor when pressing Backspace in empty field
onKeyStroke(
'Backspace',
() => {
if (props.modelValue === '') emit('update:model-value', { value: '', segments: [] });
},
{ target: root },
);
const {
editor: editorRef,
segments,
selection,
readEditorValue,
setCursorPosition,
hasFocus,
focus,
} = useExpressionEditor({
editorRef: root,
editorValue,
extensions,
isReadOnly: computed(() => props.isReadOnly),
autocompleteTelemetry: { enabled: true, parameterPath: props.path },
additionalData: props.additionalData,
});
watch(segments.display, (newSegments) => {
emit('update:model-value', {
value: '=' + readEditorValue(),
segments: newSegments,
});
});
watch(selection, (newSelection: SelectionRange) => {
if (editorRef.value) {
emit('update:selection', {
state: editorRef.value.state,
selection: newSelection,
});
}
});
watch(hasFocus, (focused) => {
if (focused) emit('focus');
});
defineExpose({
editor: editorRef,
setCursorPosition,
focus: () => {
if (!hasFocus.value) {
setCursorPosition('lastExpression');
focus();
}
},
selectAll: () => {
editorRef.value?.dispatch({
selection: selection.value.extend(0, editorRef.value?.state.doc.length),
});
},
});
</script>
<template>
<div ref="root" title="" data-test-id="inline-expression-editor-input"></div>
</template>
<style lang="scss" scoped>
:deep(.cm-editor) {
padding-left: 0;
}
:deep(.cm-content) {
padding-left: var(--spacing-2xs);
&[aria-readonly='true'] {
background-color: var(--disabled-fill, var(--color-background-light));
border-color: var(--disabled-border, var(--border-color-base));
color: var(--disabled-color, var(--color-text-base));
cursor: not-allowed;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
}
</style>

View File

@@ -0,0 +1,100 @@
import { renderComponent } from '@/__tests__/render';
import { createTestingPinia } from '@pinia/testing';
import InlineExpressionEditorOutput from './InlineExpressionEditorOutput.vue';
describe('InlineExpressionEditorOutput.vue', () => {
test('should render duplicate segments correctly', async () => {
const { getByTestId } = renderComponent(InlineExpressionEditorOutput, {
pinia: createTestingPinia(),
props: {
visible: true,
segments: [
{
from: 0,
to: 5,
plaintext: '[1,2]',
kind: 'plaintext',
},
{
from: 0,
to: 1,
plaintext: '[',
kind: 'plaintext',
},
{
from: 1,
to: 2,
plaintext: '1',
kind: 'plaintext',
},
{
from: 2,
to: 3,
plaintext: ',',
kind: 'plaintext',
},
{
from: 3,
to: 4,
plaintext: '2',
kind: 'plaintext',
},
{
from: 4,
to: 5,
plaintext: ']',
kind: 'plaintext',
},
],
},
});
expect(getByTestId('inline-expression-editor-output')).toHaveTextContent('[1,2]');
});
test('should render segments with resolved expressions', () => {
const { getByTestId } = renderComponent(InlineExpressionEditorOutput, {
pinia: createTestingPinia(),
props: {
visible: true,
segments: [
{
kind: 'plaintext',
from: 0,
to: 6,
plaintext: 'before>',
},
{
kind: 'plaintext',
from: 6,
to: 7,
plaintext: ' ',
},
{
kind: 'resolvable',
from: 7,
to: 17,
resolvable: '{{ $now }}',
resolved: '[Object: "2024-04-18T09:03:26.651-04:00"]',
state: 'valid',
error: null,
},
{
kind: 'plaintext',
from: 17,
to: 18,
plaintext: ' ',
},
{
kind: 'plaintext',
from: 18,
to: 24,
plaintext: '<after',
},
],
},
});
expect(getByTestId('inline-expression-editor-output')).toHaveTextContent(
'before> [Object: "2024-04-18T09:03:26.651-04:00"] <after',
);
});
});

View File

@@ -0,0 +1,113 @@
<script setup lang="ts">
import type { EditorState, SelectionRange } from '@codemirror/state';
import { useI18n } from '@/composables/useI18n';
import { useNDVStore } from '@/stores/ndv.store';
import type { Segment } from '@/types/expressions';
import { onBeforeUnmount } from 'vue';
import ExpressionOutput from './ExpressionOutput.vue';
import OutputItemSelect from './OutputItemSelect.vue';
import InlineExpressionTip from './InlineExpressionTip.vue';
import { outputTheme } from './theme';
interface InlineExpressionEditorOutputProps {
segments: Segment[];
unresolvedExpression?: string;
editorState?: EditorState;
selection?: SelectionRange;
visible?: boolean;
isReadOnly?: boolean;
}
withDefaults(defineProps<InlineExpressionEditorOutputProps>(), {
visible: false,
editorState: undefined,
selection: undefined,
isReadOnly: false,
unresolvedExpression: undefined,
});
const i18n = useI18n();
const theme = outputTheme();
const ndvStore = useNDVStore();
onBeforeUnmount(() => {
ndvStore.expressionOutputItemIndex = 0;
});
</script>
<template>
<div v-if="visible" :class="$style.dropdown" title="">
<div :class="$style.header">
<n8n-text bold size="small" compact>
{{ i18n.baseText('parameterInput.result') }}
</n8n-text>
<OutputItemSelect />
</div>
<n8n-text :class="$style.body">
<ExpressionOutput
data-test-id="inline-expression-editor-output"
:segments="segments"
:extensions="theme"
>
</ExpressionOutput>
</n8n-text>
<div :class="$style.footer" v-if="!isReadOnly">
<InlineExpressionTip
:editor-state="editorState"
:selection="selection"
:unresolved-expression="unresolvedExpression"
/>
</div>
</div>
</template>
<style lang="scss" module>
.dropdown {
display: flex;
flex-direction: column;
position: absolute;
z-index: 2; // cover tooltips
background: var(--color-code-background);
border: var(--border-base);
border-top: none;
width: 100%;
box-shadow: 0 2px 6px 0 rgba(#441c17, 0.1);
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
:global(.cm-editor) {
background-color: var(--color-code-background);
}
.body {
padding: var(--spacing-3xs);
}
.footer {
border-top: var(--border-base);
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--spacing-2xs);
color: var(--color-text-dark);
font-weight: var(--font-weight-bold);
padding: 0 var(--spacing-2xs);
padding-top: var(--spacing-2xs);
}
.body {
padding-top: 0;
padding-left: var(--spacing-2xs);
color: var(--color-text-dark);
&:first-child {
padding-top: var(--spacing-2xs);
}
}
}
</style>

View File

@@ -0,0 +1,140 @@
import { renderComponent } from '@/__tests__/render';
import InlineExpressionTip from '@/components/InlineExpressionEditor/InlineExpressionTip.vue';
import { FIELDS_SECTION } from '@/plugins/codemirror/completions/constants';
import type { useNDVStore } from '@/stores/ndv.store';
import type { CompletionResult } from '@codemirror/autocomplete';
import { EditorSelection, EditorState } from '@codemirror/state';
import { createTestingPinia } from '@pinia/testing';
import { waitFor } from '@testing-library/vue';
let mockNdvState: Partial<ReturnType<typeof useNDVStore>>;
let mockCompletionResult: Partial<CompletionResult>;
vi.mock('@/stores/ndv.store', () => {
return {
useNDVStore: vi.fn(() => mockNdvState),
};
});
vi.mock('@/plugins/codemirror/completions/datatype.completions', () => {
return {
datatypeCompletions: vi.fn(() => mockCompletionResult),
};
});
describe('InlineExpressionTip.vue', () => {
beforeEach(() => {
mockNdvState = {
hasInputData: true,
isInputPanelEmpty: true,
isOutputPanelEmpty: true,
setHighlightDraggables: vi.fn(),
};
});
test('should show the default tip', async () => {
const { container } = renderComponent(InlineExpressionTip, {
pinia: createTestingPinia(),
});
expect(container).toHaveTextContent('Tip: Anything inside {{ }} is JavaScript. Learn more');
});
describe('When the NDV input is not empty and a mappable input is focused', () => {
test('should show the drag-n-drop tip', async () => {
mockNdvState = {
hasInputData: true,
isInputPanelEmpty: false,
isOutputPanelEmpty: false,
focusedMappableInput: 'Some Input',
setHighlightDraggables: vi.fn(),
};
const { container, unmount } = renderComponent(InlineExpressionTip, {
pinia: createTestingPinia(),
});
expect(mockNdvState.setHighlightDraggables).toHaveBeenCalledWith(true);
expect(container).toHaveTextContent('Tip: Drag aninput fieldfrom the left to use it here.');
unmount();
expect(mockNdvState.setHighlightDraggables).toHaveBeenCalledWith(false);
});
});
describe('When the node has no input data', () => {
test('should show the execute previous nodes tip', async () => {
mockNdvState = {
hasInputData: false,
isInputParentOfActiveNode: true,
isInputPanelEmpty: false,
isOutputPanelEmpty: false,
focusedMappableInput: 'Some Input',
setHighlightDraggables: vi.fn(),
};
const { container } = renderComponent(InlineExpressionTip, {
pinia: createTestingPinia(),
});
expect(container).toHaveTextContent('Tip: Execute previous nodes to use input data');
});
});
describe('When the expression can be autocompleted with a dot', () => {
test('should show the correct tip for objects', async () => {
mockNdvState = {
hasInputData: true,
isInputPanelEmpty: false,
isOutputPanelEmpty: false,
focusedMappableInput: 'Some Input',
setHighlightDraggables: vi.fn(),
};
mockCompletionResult = { options: [{ label: 'foo', section: FIELDS_SECTION }] };
const selection = EditorSelection.cursor(8);
const expression = '{{ $json }}';
const { rerender, container } = renderComponent(InlineExpressionTip, {
pinia: createTestingPinia(),
});
await rerender({
editorState: EditorState.create({
doc: expression,
selection: EditorSelection.create([selection]),
}),
selection,
unresolvedExpression: expression,
});
await waitFor(() =>
expect(container).toHaveTextContent(
'Tip: Type . for data transformation options, or to access fields. Learn more',
),
);
});
test('should show the correct tip for primitives', async () => {
mockNdvState = {
hasInputData: true,
isInputPanelEmpty: false,
isOutputPanelEmpty: false,
focusedMappableInput: 'Some Input',
setHighlightDraggables: vi.fn(),
};
mockCompletionResult = { options: [{ label: 'foo' }] };
const selection = EditorSelection.cursor(12);
const expression = '{{ $json.foo }}';
const { rerender, container } = renderComponent(InlineExpressionTip, {
pinia: createTestingPinia(),
});
await rerender({
editorState: EditorState.create({
doc: expression,
selection: EditorSelection.create([selection]),
}),
selection,
unresolvedExpression: expression,
});
await waitFor(() =>
expect(container).toHaveTextContent(
'Tip: Type . for data transformation options. Learn more',
),
);
});
});
});

View File

@@ -0,0 +1,197 @@
<script setup lang="ts">
import { useI18n } from '@/composables/useI18n';
import { FIELDS_SECTION } from '@/plugins/codemirror/completions/constants';
import { datatypeCompletions } from '@/plugins/codemirror/completions/datatype.completions';
import { isCompletionSection } from '@/plugins/codemirror/completions/utils';
import { useNDVStore } from '@/stores/ndv.store';
import { type Completion, CompletionContext } from '@codemirror/autocomplete';
import { EditorSelection, EditorState, type SelectionRange } from '@codemirror/state';
import { watchDebounced } from '@vueuse/core';
import { computed, onBeforeUnmount, ref, watch } from 'vue';
type TipId = 'executePrevious' | 'drag' | 'default' | 'dotObject' | 'dotPrimitive';
type Props = {
editorState?: EditorState;
unresolvedExpression?: string;
selection?: SelectionRange;
};
const props = withDefaults(defineProps<Props>(), {
editorState: undefined,
unresolvedExpression: '',
selection: () => EditorSelection.cursor(0),
});
const i18n = useI18n();
const ndvStore = useNDVStore();
const canAddDotToExpression = ref(false);
const resolvedExpressionHasFields = ref(false);
const canDragToFocusedInput = computed(
() => !ndvStore.isInputPanelEmpty && ndvStore.focusedMappableInput,
);
const emptyExpression = computed(() => props.unresolvedExpression.trim().length === 0);
const tip = computed<TipId>(() => {
if (
!ndvStore.hasInputData &&
ndvStore.isInputParentOfActiveNode &&
ndvStore.focusedMappableInput
) {
return 'executePrevious';
}
if (canAddDotToExpression.value) {
return resolvedExpressionHasFields.value ? 'dotObject' : 'dotPrimitive';
}
if (canDragToFocusedInput.value && emptyExpression.value) return 'drag';
return 'default';
});
function getCompletionsWithDot(): readonly Completion[] {
if (!props.editorState || !props.selection || !props.unresolvedExpression) {
return [];
}
const cursorAfterDot = props.selection.from + 1;
const docWithDot =
props.editorState.sliceDoc(0, props.selection.from) +
'.' +
props.editorState.sliceDoc(props.selection.to);
const selectionWithDot = EditorSelection.create([EditorSelection.cursor(cursorAfterDot)]);
if (cursorAfterDot >= docWithDot.length) {
return [];
}
const stateWithDot = EditorState.create({
doc: docWithDot,
selection: selectionWithDot,
});
const context = new CompletionContext(stateWithDot, cursorAfterDot, true);
const completionResult = datatypeCompletions(context);
return completionResult?.options ?? [];
}
onBeforeUnmount(() => {
ndvStore.setHighlightDraggables(false);
});
watch(
tip,
(newTip) => {
ndvStore.setHighlightDraggables(!ndvStore.isMappingOnboarded && newTip === 'drag');
},
{ immediate: true },
);
watchDebounced(
[() => props.selection, () => props.unresolvedExpression],
() => {
const completions = getCompletionsWithDot();
canAddDotToExpression.value = completions.length > 0;
resolvedExpressionHasFields.value = completions.some(
({ section }) => isCompletionSection(section) && section.name === FIELDS_SECTION.name,
);
},
{ debounce: 200 },
);
</script>
<template>
<div :class="[$style.tip, { [$style.drag]: tip === 'drag' }]">
<n8n-text size="small" :class="$style.tipText"
>{{ i18n.baseText('parameterInput.tip') }}:
</n8n-text>
<div v-if="tip === 'drag'" :class="$style.content">
<n8n-text size="small" :class="$style.text">
{{ i18n.baseText('parameterInput.dragTipBeforePill') }}
</n8n-text>
<div :class="[$style.pill, { [$style.highlight]: !ndvStore.isMappingOnboarded }]">
{{ i18n.baseText('parameterInput.inputField') }}
</div>
<n8n-text size="small" :class="$style.text">
{{ i18n.baseText('parameterInput.dragTipAfterPill') }}
</n8n-text>
</div>
<div v-else-if="tip === 'executePrevious'" :class="$style.content">
<span> {{ i18n.baseText('expressionTip.noExecutionData') }} </span>
</div>
<div v-else-if="tip === 'dotPrimitive'" :class="$style.content">
<span v-n8n-html="i18n.baseText('expressionTip.typeDotPrimitive')" />
</div>
<div v-else-if="tip === 'dotObject'" :class="$style.content">
<span v-n8n-html="i18n.baseText('expressionTip.typeDotObject')" />
</div>
<div v-else :class="$style.content">
<span v-n8n-html="i18n.baseText('expressionTip.javascript')" />
</div>
</div>
</template>
<style lang="scss" module>
.tip {
display: flex;
align-items: center;
gap: var(--spacing-4xs);
line-height: var(--font-line-height-regular);
color: var(--color-text-base);
font-size: var(--font-size-2xs);
padding: var(--spacing-2xs);
code {
font-size: var(--font-size-3xs);
background: var(--color-background-base);
padding: var(--spacing-5xs);
border-radius: var(--border-radius-base);
}
}
.content {
display: inline-block;
}
.tipText {
display: inline;
color: var(--color-text-dark);
font-weight: var(--font-weight-bold);
}
.drag .tipText {
line-height: 21px;
}
.text {
display: inline;
}
.pill {
display: inline-flex;
align-items: center;
color: var(--color-text-dark);
border: var(--border-base);
border-color: var(--color-foreground-light);
background-color: var(--color-background-xlight);
padding: var(--spacing-5xs) var(--spacing-3xs);
margin: 0 var(--spacing-4xs);
border-radius: var(--border-radius-base);
}
.highlight {
color: var(--color-primary);
background-color: var(--color-primary-tint-3);
border-color: var(--color-primary-tint-1);
}
</style>

View File

@@ -0,0 +1,118 @@
<script setup lang="ts">
import { useI18n } from '@/composables/useI18n';
import { useNDVStore } from '@/stores/ndv.store';
import { computed } from 'vue';
const i18n = useI18n();
const ndvStore = useNDVStore();
const hoveringItem = computed(() => ndvStore.getHoveringItem);
const hoveringItemIndex = computed(() => hoveringItem.value?.itemIndex);
const isHoveringItem = computed(() => Boolean(hoveringItem.value));
const itemsLength = computed(() => ndvStore.ndvInputDataWithPinnedData.length);
const itemIndex = computed(
() => hoveringItemIndex.value ?? ndvStore.expressionOutputItemIndex ?? 0,
);
const max = computed(() => Math.max(itemsLength.value - 1, 0));
const isItemIndexEditable = computed(() => !isHoveringItem.value && itemsLength.value > 0);
const hideTableHoverHint = computed(() => ndvStore.isTableHoverOnboarded);
const canSelectPrevItem = computed(() => isItemIndexEditable.value && itemIndex.value !== 0);
const canSelectNextItem = computed(
() => isItemIndexEditable.value && itemIndex.value < itemsLength.value - 1,
);
const inputCharWidth = computed(() => itemIndex.value.toString().length);
function updateItemIndex(index: number) {
ndvStore.expressionOutputItemIndex = index;
}
function nextItem() {
ndvStore.expressionOutputItemIndex = ndvStore.expressionOutputItemIndex + 1;
}
function prevItem() {
ndvStore.expressionOutputItemIndex = ndvStore.expressionOutputItemIndex - 1;
}
</script>
<template>
<div :class="$style.item">
<n8n-text size="small" color="text-base" compact>
{{ i18n.baseText('parameterInput.item') }}
</n8n-text>
<div :class="$style.controls">
<N8nInputNumber
data-test-id="inline-expression-editor-item-input"
size="mini"
:controls="false"
:class="[$style.input, { [$style.hovering]: isHoveringItem }]"
:min="0"
:max="max"
:model-value="itemIndex"
:style="{ '--input-width': `calc(${inputCharWidth}ch + var(--spacing-s))` }"
@update:model-value="updateItemIndex"
></N8nInputNumber>
<N8nIconButton
data-test-id="inline-expression-editor-item-prev"
icon="chevron-left"
type="tertiary"
text
size="mini"
:disabled="!canSelectPrevItem"
@click="prevItem"
></N8nIconButton>
<N8nTooltip placement="right" :disabled="hideTableHoverHint">
<template #content>
<div>{{ i18n.baseText('parameterInput.hoverTableItemTip') }}</div>
</template>
<N8nIconButton
data-test-id="inline-expression-editor-item-next"
icon="chevron-right"
type="tertiary"
text
size="mini"
:disabled="!canSelectNextItem"
@click="nextItem"
></N8nIconButton>
</N8nTooltip>
</div>
</div>
</template>
<style lang="scss" module>
.item {
display: flex;
align-items: center;
gap: var(--spacing-4xs);
}
.controls {
display: flex;
align-items: center;
}
.controls .input {
--input-height: 22px;
--input-border-top-left-radius: var(--border-radius-base);
--input-border-bottom-left-radius: var(--border-radius-base);
--input-border-top-right-radius: var(--border-radius-base);
--input-border-bottom-right-radius: var(--border-radius-base);
line-height: calc(var(--input-height) - var(--spacing-4xs));
&.hovering {
--input-font-color: var(--color-secondary);
}
:global(.el-input__inner) {
height: var(--input-height);
min-height: var(--input-height);
line-height: var(--input-height);
text-align: center;
padding: 0 var(--spacing-4xs);
max-width: var(--input-width);
}
}
</style>

View File

@@ -0,0 +1,84 @@
import { EditorView } from '@codemirror/view';
import { highlighter } from '@/plugins/codemirror/resolvableHighlighter';
const commonThemeProps = (isReadOnly = false) => ({
'&.cm-focused': {
outline: '0 !important',
},
'.cm-content': {
fontFamily: 'var(--font-family-monospace)',
color: 'var(--input-font-color, var(--color-text-dark))',
caretColor: isReadOnly ? 'transparent' : 'var(--color-code-caret)',
},
'.cm-line': {
padding: '0',
},
});
export const inputTheme = ({ rows, isReadOnly } = { rows: 5, isReadOnly: false }) => {
const maxHeight = Math.max(rows * 22 + 8);
const theme = EditorView.theme({
...commonThemeProps(isReadOnly),
'&': {
maxHeight: `${maxHeight}px`,
minHeight: '30px',
width: '100%',
fontSize: 'var(--font-size-2xs)',
padding: '0 0 0 var(--spacing-2xs)',
borderWidth: 'var(--border-width-base)',
borderStyle: 'var(--input-border-style, var(--border-style-base))',
borderColor: 'var(--input-border-color, var(--border-color-base))',
borderRightColor:
'var(--input-border-right-color,var(--input-border-color, var(--border-color-base)))',
borderBottomColor:
'var(--input-border-bottom-color,var(--input-border-color, var(--border-color-base)))',
borderRadius: 'var(--input-border-radius, var(--border-radius-base))',
borderTopLeftRadius: 0,
borderTopRightRadius:
'var(--input-border-top-right-radius, var(--input-border-radius, var(--border-radius-base)))',
borderBottomLeftRadius: 0,
borderBottomRightRadius:
'var(--input-border-bottom-right-radius, var(--input-border-radius, var(--border-radius-base)))',
backgroundColor: 'white',
},
'.cm-cursor, .cm-dropCursor': {
borderLeftColor: 'var(--color-code-caret)',
},
'.cm-scroller': {
lineHeight: '1.68',
},
'.cm-lineWrapping': {
wordBreak: 'break-all',
},
});
return [theme, highlighter.resolvableStyle];
};
export const outputTheme = () => {
const theme = EditorView.theme({
...commonThemeProps(true),
'&': {
maxHeight: '95px',
width: '100%',
fontSize: 'var(--font-size-2xs)',
padding: '0',
borderTopLeftRadius: '0',
borderBottomLeftRadius: '0',
backgroundColor: 'white',
},
'.cm-scroller': {
lineHeight: '1.6',
},
'.cm-valid-resolvable': {
padding: '0 2px',
borderRadius: '2px',
},
'.cm-invalid-resolvable': {
padding: '0 2px',
borderRadius: '2px',
},
});
return [theme, highlighter.resolvableStyle];
};