trypost/resources/js/components/CodeEditor.vue
Paulo Castellano a09b1f45c2 Structure brand voice and make generated copy platform-aware
Replace free-text brand_tone/brand_voice_notes with a single structured
brand_voice_traits JSON column backed by the BrandVoiceTrait enum, exposed
as choice-chip pills in the brand settings UI and autofillable from a site.
Brand voice and visuals become per-automation toggles on the Generate node.

Unify the image controls into one 0-10 picker (0 = text-only, 1 = single,
2+ = carousel) and feed the generator the most restrictive selected network
so copy fits every platform. Pass that same platform context through the
humanizer pass — extracted into a shared ResolvesPlatformCopyBudget trait —
so the rewrite can no longer drift past the character cap the generator
respected, in both the automation and manual creation flows.

Persist the trigger node's schedule editor fields on save (they were
silently dropped by validated() for lacking validation rules).
2026-06-12 17:09:39 -03:00

333 lines
11 KiB
Vue

<script setup lang="ts">
import { autocompletion } from '@codemirror/autocomplete';
import { indentWithTab } from '@codemirror/commands';
import { json } from '@codemirror/lang-json';
import { type Extension, EditorState } from '@codemirror/state';
import {
Decoration,
type DecorationSet,
EditorView,
keymap,
MatchDecorator,
placeholder as placeholderExt,
ViewPlugin,
type ViewUpdate,
} from '@codemirror/view';
import { IconArrowsMaximize, IconArrowsMinimize, IconCopy } from '@tabler/icons-vue';
import { basicSetup } from 'codemirror';
import { computed, inject, onBeforeUnmount, onMounted, type Ref, ref, watch } from 'vue';
import { Button } from '@/components/ui/button';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import {
type ExpressionSuggestion,
expressionCompletionSource,
isKnownExpression,
} from '@/composables/useExpressionCompletions';
import debounce from '@/debounce';
import { copyToClipboard } from '@/lib/utils';
const props = withDefaults(
defineProps<{
modelValue: string;
language?: 'json' | 'text';
readOnly?: boolean;
placeholder?: string;
expandable?: boolean;
label?: string;
}>(),
{
language: 'json',
readOnly: false,
placeholder: '',
expandable: false,
label: '',
},
);
const emit = defineEmits<{
'update:modelValue': [value: string];
}>();
// Provided by the automation editor (Form.vue) for the currently selected node.
// Empty elsewhere, so the editor degrades to a plain field with no `{{ ... }}`
// suggestions.
const expressionCompletions = inject<Ref<ExpressionSuggestion[]>>(
'automationExpressionCompletions',
ref([]),
);
// Shared with Form.vue: toggling `.active` slides out the side panel (and shifts
// the sidebar to make room). Null outside the automation editor, so `expandable`
// only takes effect where Form provides the panel target.
const expandedPanel = inject<{ active: boolean } | null>('automationExpandedEditor', null);
const isExpanded = ref(false);
const canExpand = computed(() => props.expandable && expandedPanel !== null);
watch(isExpanded, (open) => {
if (expandedPanel) {
expandedPanel.active = open;
}
});
const onKeydown = (event: KeyboardEvent): void => {
if (event.key === 'Escape' && isExpanded.value) {
isExpanded.value = false;
}
};
// Paints `{{ ... }}` expressions as pills — primary-tinted when the path is
// resolvable for this node, amber/wavy when it isn't — so they read as
// variables and a wrong reference is obvious. Styles are inline on the mark so
// they win over the editor theme and JSON string-token colors.
const KNOWN_EXPR_STYLE =
'background-color:color-mix(in srgb,var(--primary) 16%,transparent);color:var(--primary);border-radius:4px;padding:1px 3px;font-weight:600;';
const UNKNOWN_EXPR_STYLE =
'background-color:color-mix(in srgb,#f59e0b 18%,transparent);color:#b45309;border-radius:4px;padding:1px 3px;font-weight:600;text-decoration:underline wavy #f59e0b;';
const expressionHighlighter = (() => {
const matcher = new MatchDecorator({
regexp: /\{\{\s*([\w.]+)\s*\}\}/g,
decoration: (match) =>
Decoration.mark({
attributes: {
style: isKnownExpression(match[1], expressionCompletions.value)
? KNOWN_EXPR_STYLE
: UNKNOWN_EXPR_STYLE,
},
}),
});
return ViewPlugin.fromClass(
class {
decorations: DecorationSet;
constructor(view: EditorView) {
this.decorations = matcher.createDeco(view);
}
update(update: ViewUpdate) {
this.decorations = matcher.updateDeco(update, this.decorations);
}
},
{ decorations: (plugin) => plugin.decorations },
);
})();
const editorContainer = ref<HTMLElement>();
let view: EditorView | null = null;
const debouncedEmit = debounce((value: string) => {
emit('update:modelValue', value);
}, 250);
const languageExtension = (): Extension => {
switch (props.language) {
case 'text':
return [];
case 'json':
default:
return json();
}
};
const lightTheme = EditorView.theme({
'&': {
height: '100%',
fontSize: '13px',
color: 'var(--foreground)',
backgroundColor: 'var(--card)',
border: '2px solid var(--foreground)',
borderRadius: 'var(--radius-md)',
overflow: 'hidden',
},
'&.cm-focused': {
outline: '2px solid var(--ring)',
outlineOffset: '0px',
},
'.cm-scroller': {
overflow: 'auto',
fontFamily: 'var(--font-mono)',
lineHeight: '1.6',
},
'.cm-content': {
caretColor: 'var(--foreground)',
padding: '8px 0',
},
'.cm-gutters': {
backgroundColor: 'var(--muted)',
color: 'var(--muted-foreground)',
border: 'none',
borderRight: '2px solid color-mix(in srgb, var(--foreground) 15%, transparent)',
},
'.cm-activeLine': {
backgroundColor: 'color-mix(in srgb, var(--foreground) 4%, transparent)',
},
'.cm-activeLineGutter': {
backgroundColor: 'color-mix(in srgb, var(--foreground) 8%, transparent)',
},
'.cm-selectionBackground, &.cm-focused .cm-selectionBackground, .cm-content ::selection':
{
backgroundColor: 'color-mix(in srgb, var(--ring) 20%, transparent)',
},
'.cm-cursor, .cm-dropCursor': {
borderLeftColor: 'var(--foreground)',
},
'.cm-placeholder': {
color: 'var(--muted-foreground)',
},
});
onMounted(() => {
if (!editorContainer.value) {
return;
}
const updateListener = EditorView.updateListener.of((update) => {
if (update.docChanged) {
debouncedEmit(update.state.doc.toString());
}
});
const extensions = [
basicSetup,
keymap.of([indentWithTab]),
languageExtension(),
EditorView.lineWrapping,
autocompletion({
override: [expressionCompletionSource(() => expressionCompletions.value)],
activateOnTyping: true,
icons: false,
}),
expressionHighlighter,
lightTheme,
updateListener,
];
if (props.placeholder) {
extensions.push(placeholderExt(props.placeholder));
}
if (props.readOnly) {
extensions.push(EditorState.readOnly.of(true));
extensions.push(EditorView.editable.of(false));
}
view = new EditorView({
state: EditorState.create({
doc: props.modelValue ?? '',
extensions,
}),
parent: editorContainer.value,
});
if (props.expandable) {
window.addEventListener('keydown', onKeydown);
}
});
watch(
() => props.modelValue,
(value) => {
if (!view) {
return;
}
const current = view.state.doc.toString();
if ((value ?? '') !== current) {
view.dispatch({
changes: {
from: 0,
to: current.length,
insert: value ?? '',
},
});
}
},
);
onBeforeUnmount(() => {
debouncedEmit.cancel();
view?.destroy();
view = null;
window.removeEventListener('keydown', onKeydown);
// Switching nodes unmounts the editor; make sure the side panel collapses
// instead of lingering empty.
if (isExpanded.value && expandedPanel) {
expandedPanel.active = false;
}
});
</script>
<template>
<div class="group relative h-full w-full">
<div ref="editorContainer" class="code-editor h-full w-full" />
<TooltipProvider :delay-duration="200">
<div
v-if="!isExpanded && (canExpand || modelValue)"
class="absolute right-2 top-2 z-10 flex gap-0.5 rounded-lg border-2 border-foreground bg-card p-0.5 opacity-0 shadow-[1px_1px_0_var(--foreground)] transition-opacity duration-150 group-hover:opacity-100 focus-within:opacity-100"
>
<Tooltip v-if="canExpand">
<TooltipTrigger as-child>
<button
type="button"
class="inline-flex size-6 items-center justify-center rounded-md text-foreground/70 transition-colors hover:bg-foreground/10 hover:text-foreground"
:aria-label="$t('automations.config.expand_editor')"
@click="isExpanded = true"
>
<IconArrowsMaximize class="size-3.5" stroke-width="2.5" />
</button>
</TooltipTrigger>
<TooltipContent>{{ $t('automations.config.expand_editor') }}</TooltipContent>
</Tooltip>
<Tooltip v-if="modelValue">
<TooltipTrigger as-child>
<button
type="button"
class="inline-flex size-6 items-center justify-center rounded-md text-foreground/70 transition-colors hover:bg-foreground/10 hover:text-foreground"
:aria-label="$t('common.actions.copy')"
@click="copyToClipboard(modelValue)"
>
<IconCopy class="size-3.5" stroke-width="2.5" />
</button>
</TooltipTrigger>
<TooltipContent>{{ $t('common.actions.copy') }}</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
<Teleport v-if="canExpand && isExpanded" to="#automation-expanded-editor">
<div class="flex shrink-0 items-center justify-between gap-2 border-b-2 border-foreground/10 px-3 py-3">
<span class="truncate text-sm font-bold">{{ label || $t('automations.config.expand_editor') }}</span>
<TooltipProvider :delay-duration="200">
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="icon-sm" :aria-label="$t('automations.config.minimize_editor')" @click="isExpanded = false">
<IconArrowsMinimize class="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ $t('automations.config.minimize_editor') }}</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div class="min-h-0 flex-1 p-4">
<CodeEditor
:model-value="modelValue"
:language="language"
:placeholder="placeholder"
:read-only="readOnly"
@update:model-value="emit('update:modelValue', $event)"
/>
</div>
</Teleport>
</div>
</template>