31940461c6
electron был не нужен вообще — тянулся транзитивно только через неиспользуемую devDependency @vue/devtools (нет ни одного импорта в коде). Убрана из desktop/package.json и из onlyBuiltDependencies корня; lockfile пересобран. Это устраняет флап CI: pnpm install изредка валился на сетевой ошибке при скачивании ~200MB бинарника electron (run #745 и run #732 на этом PR), из-за чего typecheck падал до первой TS-проверки. Так как install перестал падать раньше срока, vue-tsc теперь реально доходит до конца и вскрывает предсуществующие баги: - useCapitalProgramDocParams.ts, CapitalProgramDocumentParametersWidget.vue: импорт `CapitalProgramPrivateData` напрямую из 'cooptypes' никогда не работал — тип лежит в Cooperative.Registry, не на верхнем уровне (остальной код capital-расширения уже импортирует так же). Каст результата Object.fromEntries (индексная сигнатура) в интерфейс с именованными полями TS отклоняет как insufficient overlap; вместо unknown-каста — каст в Record<EditableFieldKey, string> (тот же паттерн, что и в соседней createEmptyForm), который затем обычной структурной проверкой присваивается в CapitalProgramPrivateData, т.к. набор из 11 полей совпадает один в один. - CapitalProgramDocumentParametersWidget.vue: legacy draft.activeTab стал optional при переходе на wizardStepKey и больше не записывается читалкой драфта — добавлен фолбэк на дефолт 994, как и в самой readCapitalProgramDocParamsDraft. - CapitalProgramInlineDocumentPreview.vue: querySelectorAll с составным CSS-селектором типизируется как NodeListOf<Element>; cleanupEditors использовал узел как HTMLTextAreaElement без сужения (соседняя syncInlineEditors уже делает `as HTMLTextAreaElement` для той же ситуации). - ExtensionInstall.vue: prop schema был типизирован как unknown, хотя передаётся напрямую в ZodForm, которому нужен IExtensionConfigSchema — типизирован точно под фактическое использование. - sdk/queries/paymentMethods/getPaymentMethods.ts: `const name` был без export вопреки конвенции всех остальных query-модулей (Queries.X.Y.name используется для чтения ключа GraphQL-ответа). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
203 lines
5.2 KiB
Vue
203 lines
5.2 KiB
Vue
<template lang="pug">
|
|
.capital-inline-doc.statement(ref='containerRef')
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import DOMPurify from 'dompurify';
|
|
import { nextTick, onBeforeUnmount, ref, watch } from 'vue';
|
|
|
|
import type { EditableFieldKey } from '../model/capitalProgramDocFields';
|
|
|
|
const props = defineProps<{
|
|
html: string;
|
|
fieldLabels: Partial<Record<EditableFieldKey, string>>;
|
|
}>();
|
|
|
|
const form = defineModel<Record<EditableFieldKey, string>>({ required: true });
|
|
|
|
const containerRef = ref<HTMLElement | null>(null);
|
|
const listeners = new WeakMap<HTMLTextAreaElement, () => void>();
|
|
|
|
type FocusSnapshot = {
|
|
key: EditableFieldKey;
|
|
selectionStart: number;
|
|
selectionEnd: number;
|
|
};
|
|
|
|
let focusSnapshot: FocusSnapshot | null = null;
|
|
|
|
function isFieldEmpty(key: EditableFieldKey): boolean {
|
|
return !String(form.value[key] ?? '').trim();
|
|
}
|
|
|
|
function applyFieldStyle(textarea: HTMLTextAreaElement, key: EditableFieldKey) {
|
|
textarea.classList.toggle('capital-doc-param-inline--empty', isFieldEmpty(key));
|
|
}
|
|
|
|
function captureFocus() {
|
|
const active = document.activeElement;
|
|
if (!(active instanceof HTMLTextAreaElement) || !active.classList.contains('capital-doc-param-inline')) {
|
|
focusSnapshot = null;
|
|
return;
|
|
}
|
|
|
|
const key = active.getAttribute('data-field') as EditableFieldKey | null;
|
|
if (!key) {
|
|
focusSnapshot = null;
|
|
return;
|
|
}
|
|
|
|
focusSnapshot = {
|
|
key,
|
|
selectionStart: active.selectionStart,
|
|
selectionEnd: active.selectionEnd,
|
|
};
|
|
}
|
|
|
|
function restoreFocus(root: HTMLElement) {
|
|
if (!focusSnapshot) return;
|
|
|
|
const textarea = root.querySelector(`textarea[data-field="${focusSnapshot.key}"]`) as HTMLTextAreaElement | null;
|
|
if (!textarea) return;
|
|
|
|
textarea.focus();
|
|
textarea.setSelectionRange(focusSnapshot.selectionStart, focusSnapshot.selectionEnd);
|
|
}
|
|
|
|
function cleanupEditors(root: HTMLElement) {
|
|
root.querySelectorAll('textarea.capital-doc-param-inline').forEach((node) => {
|
|
const textarea = node as HTMLTextAreaElement;
|
|
const handler = listeners.get(textarea);
|
|
if (handler) {
|
|
textarea.removeEventListener('input', handler);
|
|
listeners.delete(textarea);
|
|
}
|
|
});
|
|
}
|
|
|
|
function mountInlineEditors(root: HTMLElement) {
|
|
root.querySelectorAll('[data-doc-param]').forEach((node) => {
|
|
const key = node.getAttribute('data-doc-param') as EditableFieldKey | null;
|
|
if (!key) return;
|
|
|
|
const textarea = document.createElement('textarea');
|
|
textarea.className = 'capital-doc-param-inline';
|
|
textarea.setAttribute('data-field', key);
|
|
textarea.rows =
|
|
key.includes('description') ||
|
|
key.includes('expansion') ||
|
|
key.includes('development') ||
|
|
key.includes('goal') ||
|
|
key.includes('purpose')
|
|
? 3
|
|
: 2;
|
|
textarea.value = form.value[key] ?? '';
|
|
textarea.placeholder = props.fieldLabels[key] ?? key;
|
|
applyFieldStyle(textarea, key);
|
|
|
|
const onInput = () => {
|
|
form.value[key] = textarea.value;
|
|
applyFieldStyle(textarea, key);
|
|
};
|
|
textarea.addEventListener('input', onInput);
|
|
listeners.set(textarea, onInput);
|
|
|
|
node.replaceWith(textarea);
|
|
});
|
|
}
|
|
|
|
function syncInlineEditors(root: HTMLElement) {
|
|
root.querySelectorAll('textarea.capital-doc-param-inline').forEach((node) => {
|
|
const textarea = node as HTMLTextAreaElement;
|
|
const key = textarea.getAttribute('data-field') as EditableFieldKey | null;
|
|
if (!key) return;
|
|
|
|
if (textarea !== document.activeElement && textarea.value !== (form.value[key] ?? '')) {
|
|
textarea.value = form.value[key] ?? '';
|
|
}
|
|
|
|
applyFieldStyle(textarea, key);
|
|
});
|
|
}
|
|
|
|
async function renderDocument(html: string) {
|
|
await nextTick();
|
|
const root = containerRef.value;
|
|
if (!root) return;
|
|
|
|
captureFocus();
|
|
cleanupEditors(root);
|
|
root.innerHTML = DOMPurify.sanitize(html);
|
|
mountInlineEditors(root);
|
|
restoreFocus(root);
|
|
}
|
|
|
|
watch(
|
|
() => props.html,
|
|
(html) => {
|
|
if (html) void renderDocument(html);
|
|
},
|
|
{ immediate: true },
|
|
);
|
|
|
|
watch(
|
|
form,
|
|
() => {
|
|
if (containerRef.value) syncInlineEditors(containerRef.value);
|
|
},
|
|
{ deep: true },
|
|
);
|
|
|
|
onBeforeUnmount(() => {
|
|
if (containerRef.value) cleanupEditors(containerRef.value);
|
|
});
|
|
</script>
|
|
|
|
<style scoped lang="scss">
|
|
.capital-inline-doc {
|
|
max-height: 60vh;
|
|
overflow: auto;
|
|
padding: var(--p-4);
|
|
border: 1px solid var(--p-line);
|
|
border-radius: var(--p-r-md);
|
|
background: var(--p-surface);
|
|
}
|
|
|
|
.capital-inline-doc :deep(.capital-doc-param-inline) {
|
|
display: block;
|
|
width: 100%;
|
|
max-width: 100%;
|
|
margin: var(--p-1) 0;
|
|
padding: var(--p-2) var(--p-3);
|
|
font: inherit;
|
|
line-height: inherit;
|
|
color: var(--p-ink);
|
|
background: color-mix(in srgb, var(--p-primary) 8%, var(--p-surface));
|
|
border: 1px dashed var(--p-primary);
|
|
border-radius: var(--p-r-sm);
|
|
resize: vertical;
|
|
box-sizing: border-box;
|
|
white-space: pre-wrap;
|
|
|
|
&:focus {
|
|
outline: none;
|
|
border-style: solid;
|
|
background: color-mix(in srgb, var(--p-primary) 12%, var(--p-surface));
|
|
}
|
|
|
|
&::placeholder {
|
|
color: var(--p-ink-muted);
|
|
font-size: 0.85em;
|
|
}
|
|
}
|
|
|
|
.capital-inline-doc :deep(.capital-doc-param-inline--empty) {
|
|
border-color: var(--p-warn);
|
|
background: color-mix(in srgb, var(--p-warn) 12%, var(--p-surface));
|
|
|
|
&:focus {
|
|
background: color-mix(in srgb, var(--p-warn) 16%, var(--p-surface));
|
|
}
|
|
}
|
|
</style>
|