import { countTag } from '../domain/field-types';
|
import { parseTag } from '../domain/tags';
|
import type { FieldDefinition, TemplateBinding, TemplateControlSnapshot } from '../domain/types';
|
import type { FormulaResultLockSnapshot } from '../domain/formula-mapping';
|
import {
|
disableTrackRevisionsCommand,
|
insertTypedControlCommand,
|
listContentControlsCommand,
|
readControlTextCommand,
|
readTrackRevisionsStateCommand,
|
setFormulaResultLockCommand,
|
updateControlSpecCommand,
|
} from './commands';
|
import type { AscGlobal, OfficeContentControl, OnlyOfficeCommand } from './types';
|
|
interface CommandResult {
|
controls?: OfficeContentControl[];
|
error?: string;
|
hasRevisionChanges?: boolean;
|
ok: boolean;
|
snapshot?: unknown;
|
text?: string;
|
trackRevisions?: boolean;
|
}
|
|
interface LifecycleCallbacks {
|
onReady: () => void;
|
onThemeChanged: (theme: unknown) => void;
|
}
|
|
interface LifecycleBinding {
|
callbacks: LifecycleCallbacks;
|
}
|
|
interface LifecycleState {
|
bindings: LifecycleBinding[];
|
button: NonNullable<AscGlobal['plugin']['button']>;
|
init: NonNullable<AscGlobal['plugin']['init']>;
|
onThemeChanged: NonNullable<AscGlobal['plugin']['onThemeChanged']>;
|
originalButton: AscGlobal['plugin']['button'];
|
originalInit: AscGlobal['plugin']['init'];
|
originalThemeChanged: AscGlobal['plugin']['onThemeChanged'];
|
}
|
|
export interface SnapshotControlOptions {
|
inheritedFormulaLockBindingIds?: ReadonlySet<string>;
|
}
|
|
export interface OnlyOfficeBridge {
|
applyControlPlan(bindings: TemplateBinding[], desired: TemplateControlSnapshot[], signal?: AbortSignal): Promise<void>;
|
bindLifecycle(callbacks: LifecycleCallbacks): () => void;
|
close(): void;
|
deleteControl(internalId: string): Promise<OfficeContentControl[]>;
|
disableTrackRevisions(): Promise<void>;
|
getRuntimeOptions(): unknown;
|
insertBinding(binding: TemplateBinding, signal?: AbortSignal): Promise<void>;
|
insertReadOnlyBinding(binding: TemplateBinding, signal?: AbortSignal): Promise<void>;
|
insertField(definition: FieldDefinition): Promise<OfficeContentControl[]>;
|
insertReference(binding: TemplateBinding, primaryTag: string, signal?: AbortSignal): Promise<void>;
|
listControls(): Promise<OfficeContentControl[]>;
|
lockFormulaResult(primaryTag: string): Promise<FormulaResultLockSnapshot>;
|
readSelectedText(): Promise<string>;
|
unlockFormulaResult(primaryTag: string): Promise<void>;
|
restoreFormulaResultLocks(snapshot: FormulaResultLockSnapshot): Promise<void>;
|
isFormulaResultLocked(primaryTag: string): Promise<boolean>;
|
selectControl(internalId: string, signal?: AbortSignal): Promise<void>;
|
snapshotControls(bindings: TemplateBinding[], controls: OfficeContentControl[], options?: SnapshotControlOptions): TemplateControlSnapshot[];
|
}
|
|
function isFormulaLockSnapshot(value: unknown): value is FormulaResultLockSnapshot {
|
return (
|
typeof value === 'object' &&
|
value !== null &&
|
'primaryTag' in value &&
|
typeof value.primaryTag === 'string' &&
|
'locks' in value &&
|
Array.isArray(value.locks) &&
|
value.locks.every(
|
(item) => typeof item === 'object' && item !== null && 'tag' in item && typeof item.tag === 'string' && 'lock' in item && typeof item.lock === 'string',
|
)
|
);
|
}
|
|
const lifecycleStates = new WeakMap<AscGlobal, LifecycleState>();
|
|
function debugBridge(event: string, details: Record<string, unknown>): void {
|
if (import.meta.env.DEV && import.meta.env.MODE !== 'test') {
|
console.info('[ELN annotator]', new Date().toISOString(), event, details);
|
}
|
}
|
|
function toError(error: unknown): Error {
|
return error instanceof Error ? error : new Error(String(error));
|
}
|
|
function normalizeOptions(value: unknown): Array<{ display: string; value: string }> {
|
if (!Array.isArray(value)) return [];
|
return value.map((item) => {
|
if (typeof item === 'object' && item && 'display' in item && 'value' in item) {
|
return { display: String(item.display), value: String(item.value) };
|
}
|
return { display: String(item), value: String(item) };
|
});
|
}
|
|
function normalizeControlConfig(controlType: string, controlRole: string, value: null | Record<string, unknown> | undefined): Record<string, unknown> {
|
const config = value ?? {};
|
const placeholder = typeof config.placeholder === 'string' ? config.placeholder : '';
|
switch (controlType.toUpperCase()) {
|
case 'DROPDOWN':
|
return {
|
options: normalizeOptions(config.options),
|
placeholder,
|
...(controlRole === 'PRIMARY' ? { defaultValue: typeof config.defaultValue === 'string' ? config.defaultValue : '' } : {}),
|
};
|
case 'CHECKBOX':
|
return { defaultChecked: Boolean(config.defaultChecked) };
|
case 'DATE':
|
return { dateFormat: typeof config.dateFormat === 'string' && config.dateFormat ? config.dateFormat : 'yyyy-MM-dd', placeholder };
|
case 'MULTILINE':
|
case 'TEXT':
|
return { placeholder };
|
default:
|
return config;
|
}
|
}
|
|
function normalizeExistingControlConfig(controlType: string, controlRole: string, value: null | Record<string, unknown> | undefined): Record<string, unknown> {
|
if (controlType.toUpperCase() === 'CHECKBOX') return {};
|
return normalizeControlConfig(controlType, controlRole, value);
|
}
|
|
function bindingConfigForSnapshot(binding: TemplateBinding): null | Record<string, unknown> {
|
const config = binding.controlConfig ?? {};
|
const placeholder = typeof config.placeholder === 'string' ? config.placeholder : '';
|
if (binding.controlType !== 'CHECKBOX' && !placeholder.trim()) {
|
return { ...config, placeholder: binding.controlAlias || binding.fieldCode };
|
}
|
return binding.controlConfig;
|
}
|
|
function stableValue(value: unknown): unknown {
|
if (Array.isArray(value)) return value.map(stableValue);
|
if (value && typeof value === 'object') {
|
return Object.fromEntries(
|
Object.entries(value)
|
.sort(([left], [right]) => left.localeCompare(right))
|
.map(([key, item]) => [key, stableValue(item)]),
|
);
|
}
|
return value;
|
}
|
|
function configsEqual(left: Record<string, unknown>, right: Record<string, unknown>): boolean {
|
return JSON.stringify(stableValue(left)) === JSON.stringify(stableValue(right));
|
}
|
|
function snapshotControlType(bindingType: string, actualType: string): string {
|
const expected = bindingType.toUpperCase();
|
const actual = actualType.toUpperCase();
|
return expected === 'MULTILINE' && actual === 'TEXT' ? expected : actual;
|
}
|
|
function bindingLock(binding: TemplateBinding, role = binding.controlRole): 'contentLocked' | 'sdtContentLocked' | 'unlocked' {
|
if (role === 'REFERENCE') return 'contentLocked';
|
return binding.readOnly === true && binding.valueSource === 'CROSS_TEMPLATE' ? 'sdtContentLocked' : 'unlocked';
|
}
|
|
function controlMatchesTarget(binding: TemplateBinding, target: TemplateControlSnapshot, control: OfficeContentControl): boolean {
|
if (
|
typeof control.Alias !== 'string' ||
|
!control.ControlConfig ||
|
typeof control.ControlRole !== 'string' ||
|
typeof control.ControlType !== 'string' ||
|
typeof control.Lock !== 'string'
|
)
|
return false;
|
const role = target.controlRole || binding.controlRole;
|
const type = (target.controlType || binding.controlType).toUpperCase();
|
const alias = target.controlAlias || binding.controlAlias || binding.fieldCode;
|
const expectedConfig = normalizeExistingControlConfig(type, role, target.controlConfig ?? binding.controlConfig);
|
const actualConfig = normalizeExistingControlConfig(control.ControlType, control.ControlRole, control.ControlConfig);
|
return (
|
control.Alias === alias &&
|
control.ControlRole === role &&
|
control.ControlType.toUpperCase() === type &&
|
control.Lock === bindingLock(binding, role) &&
|
configsEqual(actualConfig, expectedConfig)
|
);
|
}
|
|
function isCommandResult(value: unknown): value is CommandResult {
|
return typeof value === 'object' && value !== null && !Array.isArray(value) && 'ok' in value && typeof value.ok === 'boolean';
|
}
|
|
function bindLifecycleHandlers(asc: AscGlobal, callbacks: LifecycleCallbacks): () => void {
|
let state = lifecycleStates.get(asc);
|
|
if (!state) {
|
const bindings: LifecycleBinding[] = [];
|
const currentBinding = () => bindings[bindings.length - 1];
|
state = {
|
bindings,
|
originalInit: asc.plugin.init,
|
originalButton: asc.plugin.button,
|
originalThemeChanged: asc.plugin.onThemeChanged,
|
init: () => currentBinding()?.callbacks.onReady(),
|
button: (id) => {
|
if (id === -1 && currentBinding()) {
|
asc.plugin.executeCommand('close', '');
|
}
|
},
|
onThemeChanged: (theme) => {
|
if (currentBinding()) {
|
asc.plugin.onThemeChangedBase(theme);
|
currentBinding()?.callbacks.onThemeChanged(theme);
|
}
|
},
|
};
|
lifecycleStates.set(asc, state);
|
asc.plugin.init = state.init;
|
asc.plugin.button = state.button;
|
asc.plugin.onThemeChanged = state.onThemeChanged;
|
}
|
|
const binding = { callbacks };
|
state.bindings.push(binding);
|
let active = true;
|
|
return () => {
|
if (!active) {
|
return;
|
}
|
active = false;
|
const index = state.bindings.indexOf(binding);
|
if (index >= 0) {
|
state.bindings.splice(index, 1);
|
}
|
if (state.bindings.length > 0) {
|
return;
|
}
|
|
if (asc.plugin.init === state.init) {
|
asc.plugin.init = state.originalInit;
|
}
|
if (asc.plugin.button === state.button) {
|
asc.plugin.button = state.originalButton;
|
}
|
if (asc.plugin.onThemeChanged === state.onThemeChanged) {
|
asc.plugin.onThemeChanged = state.originalThemeChanged;
|
}
|
if (lifecycleStates.get(asc) === state) {
|
lifecycleStates.delete(asc);
|
}
|
};
|
}
|
|
export function createOnlyOfficeBridge(asc: AscGlobal): OnlyOfficeBridge {
|
let verifyNativeControlTag = '';
|
let preferNativeControls = false;
|
const insertedTextDefinitions = new Map<string, FieldDefinition>();
|
|
function executeMethod(name: string, args: unknown[] = []): Promise<unknown> {
|
return new Promise((resolve, reject) => {
|
try {
|
debugBridge('method:start', { args, methodName: name });
|
asc.plugin.executeMethod(name, args, (result) => {
|
debugBridge('method:callback', { methodName: name, result });
|
resolve(result);
|
});
|
} catch (error: unknown) {
|
debugBridge('method:threw', { error: toError(error).message, methodName: name });
|
reject(toError(error));
|
}
|
});
|
}
|
|
function executeCommand(command: OnlyOfficeCommand, scopeKey: string, payload: unknown, allowMissingResult = false): Promise<CommandResult> {
|
return new Promise((resolve, reject) => {
|
const commandName = command.name || scopeKey;
|
const clearScope = () => {
|
if (asc.scope[scopeKey] === payload) {
|
delete asc.scope[scopeKey];
|
}
|
};
|
|
try {
|
asc.scope[scopeKey] = payload;
|
debugBridge('command:start', { allowMissingResult, commandName, payload, scopeKey });
|
asc.plugin.callCommand(command, false, true, (raw) => {
|
try {
|
debugBridge('command:callback', { commandName, raw, scopeKey });
|
if (raw === undefined && allowMissingResult) {
|
debugBridge('command:missing-callback-accepted', { commandName, scopeKey });
|
resolve({ ok: true });
|
return;
|
}
|
const result: unknown = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
|
if (!isCommandResult(result)) {
|
debugBridge('command:invalid-result', { commandName, raw, scopeKey });
|
reject(new Error('编辑器命令返回无效结果'));
|
return;
|
}
|
|
if (!result.ok) {
|
debugBridge('command:rejected', { commandName, error: result.error, scopeKey });
|
reject(new Error(typeof result.error === 'string' ? result.error : '编辑器命令执行失败'));
|
return;
|
}
|
|
debugBridge('command:success', { commandName, scopeKey });
|
resolve(result);
|
} catch {
|
debugBridge('command:invalid-result', { commandName, raw, scopeKey });
|
reject(new Error('编辑器命令返回无效结果'));
|
} finally {
|
clearScope();
|
}
|
});
|
} catch (error: unknown) {
|
clearScope();
|
debugBridge('command:threw', { commandName, error: toError(error).message, scopeKey });
|
reject(toError(error));
|
}
|
});
|
}
|
|
async function readControls(errorMessage: string): Promise<OfficeContentControl[]> {
|
const result = await executeCommand(listContentControlsCommand, 'elnTemplateList', {});
|
|
if (!Array.isArray(result.controls)) {
|
throw new Error(errorMessage);
|
}
|
|
const targetTag = verifyNativeControlTag;
|
const commandTargetCount = targetTag ? countTag(result.controls, targetTag) : 0;
|
const shouldReadNative = preferNativeControls || Boolean(targetTag && commandTargetCount === 0);
|
verifyNativeControlTag = '';
|
if (!shouldReadNative) return result.controls;
|
|
const rawNative = await executeMethod('GetAllContentControls');
|
if (!Array.isArray(rawNative)) throw new Error(errorMessage);
|
const native = rawNative.map((item) => nativeControl(item)).filter((control): control is OfficeContentControl => control !== null);
|
const nativeTargetCount = targetTag ? countTag(native, targetTag) : 0;
|
if (targetTag && commandTargetCount === 0 && nativeTargetCount > 0) preferNativeControls = true;
|
|
return preferNativeControls ? mergeNativeControls(native, result.controls) : result.controls;
|
}
|
|
function nativeLock(value: unknown): string | undefined {
|
if (typeof value === 'string') return value;
|
if (value === 0) return 'contentLocked';
|
if (value === 1) return 'sdtContentLocked';
|
if (value === 2) return 'sdtLocked';
|
if (value === 3) return 'unlocked';
|
return undefined;
|
}
|
|
function nativeControl(value: unknown): null | OfficeContentControl {
|
if (!value || typeof value !== 'object') return null;
|
const item = value as Record<string, unknown>;
|
const tag = typeof item.Tag === 'string' ? item.Tag : '';
|
const definition = insertedTextDefinitions.get(tag);
|
const controlRole = tag.startsWith('eln.ref.')
|
? 'REFERENCE'
|
: tag.startsWith('eln.field.')
|
? 'PRIMARY'
|
: tag.startsWith('eln.repeat.')
|
? 'REPEAT'
|
: 'UNKNOWN';
|
return {
|
Alias: typeof item.Alias === 'string' ? item.Alias : undefined,
|
ControlConfig: definition ? { placeholder: definition.placeholder } : undefined,
|
ControlRole: definition ? controlRole : undefined,
|
ControlType: definition ? 'text' : undefined,
|
InternalId: typeof item.InternalId === 'string' ? item.InternalId : undefined,
|
Lock: nativeLock(item.Lock),
|
Tag: tag,
|
};
|
}
|
|
function mergeNativeControls(native: OfficeContentControl[], detailed: OfficeContentControl[]): OfficeContentControl[] {
|
return native.map((control) => {
|
const match = detailed.find((item) => (control.InternalId && item.InternalId === control.InternalId) || (control.Tag && item.Tag === control.Tag));
|
if (!match) return control;
|
return {
|
...match,
|
...control,
|
Alias: control.Alias ?? match.Alias,
|
ControlConfig: match.ControlConfig ?? control.ControlConfig,
|
ControlRole: match.ControlRole ?? control.ControlRole,
|
ControlType: control.Tag && insertedTextDefinitions.has(control.Tag) ? control.ControlType : (match.ControlType ?? control.ControlType),
|
InternalId: control.InternalId ?? match.InternalId,
|
Lock: control.Lock ?? match.Lock,
|
Tag: control.Tag || match.Tag,
|
};
|
});
|
}
|
|
async function writeTextDefaultWithNativeControl(tag: string, value: string): Promise<void> {
|
const rawControls = await executeMethod('GetAllContentControls');
|
if (!Array.isArray(rawControls)) throw new Error('插入后无法读取文本控件');
|
const targets = rawControls.map((item) => nativeControl(item)).filter((control): control is OfficeContentControl => control?.Tag === tag);
|
if (targets.length !== 1) throw new Error('插入后 Tag 计数不是 1');
|
const target = targets[0]!;
|
if (!target?.InternalId) throw new Error('插入后无法定位文本控件');
|
|
const moved = await executeMethod('MoveCursorToContentControl', [target.InternalId, true]);
|
if (moved === false) throw new Error('插入后无法定位文本控件');
|
const input = await executeMethod('InputText', [value]);
|
if (input === false) throw new Error('文本默认值写入失败');
|
const rawCurrent = await executeMethod('GetCurrentContentControlPr', ['text']);
|
const current = rawCurrent && typeof rawCurrent === 'object' ? (rawCurrent as Record<string, unknown>) : null;
|
if (current?.InternalId !== target.InternalId || current.Tag !== tag) throw new Error('文本默认值写入后控件定位不一致');
|
if (current?.content !== value) throw new Error('文本默认值回读不一致');
|
}
|
|
function listControls(): Promise<OfficeContentControl[]> {
|
return readControls('编辑器未返回内容控件列表');
|
}
|
|
async function readSelectedText(): Promise<string> {
|
const result = await executeMethod('GetSelectedText', [{ Math: false, Numbering: false }]);
|
return typeof result === 'string' ? result : '';
|
}
|
|
async function disableTrackRevisions(): Promise<void> {
|
await executeCommand(disableTrackRevisionsCommand, 'elnTemplateReview', {}, true);
|
const state = await executeCommand(readTrackRevisionsStateCommand, 'elnTemplateReviewState', {});
|
if (state.hasRevisionChanges !== false) {
|
throw new Error('历史修订仍未全部接受');
|
}
|
if (state.trackRevisions !== false) {
|
throw new Error('修订跟踪仍处于开启状态');
|
}
|
}
|
|
async function lockFormulaResult(primaryTag: string): Promise<FormulaResultLockSnapshot> {
|
const result = await executeCommand(setFormulaResultLockCommand, 'elnTemplateFormulaLocks', { mode: 'lock', primaryTag });
|
if (!isFormulaLockSnapshot((result as { snapshot?: unknown }).snapshot)) throw new Error('编辑器未返回公式结果锁快照');
|
return (result as { snapshot: FormulaResultLockSnapshot }).snapshot;
|
}
|
|
async function restoreFormulaResultLocks(snapshot: FormulaResultLockSnapshot): Promise<void> {
|
const result = await executeCommand(setFormulaResultLockCommand, 'elnTemplateFormulaLocks', { mode: 'restore', snapshot });
|
if (!isFormulaLockSnapshot((result as { snapshot?: unknown }).snapshot)) throw new Error('编辑器未确认公式结果锁恢复');
|
}
|
|
async function unlockFormulaResult(primaryTag: string): Promise<void> {
|
const result = await executeCommand(setFormulaResultLockCommand, 'elnTemplateFormulaLocks', { mode: 'unlock', primaryTag });
|
if (!isFormulaLockSnapshot((result as { snapshot?: unknown }).snapshot)) throw new Error('编辑器未确认公式结果锁解除');
|
}
|
|
async function isFormulaResultLocked(primaryTag: string): Promise<boolean> {
|
const result = await executeCommand(setFormulaResultLockCommand, 'elnTemplateFormulaLocks', {
|
mode: 'verify',
|
primaryTag,
|
});
|
return isFormulaLockSnapshot((result as { snapshot?: unknown }).snapshot);
|
}
|
|
async function insertField(definition: FieldDefinition): Promise<OfficeContentControl[]> {
|
const before = await listControls();
|
const beforeCount = countTag(before, definition.tag);
|
debugBridge('field:before-insert', { beforeCount, controlType: definition.controlType, tag: definition.tag });
|
|
if (beforeCount > 0) {
|
throw new Error('字段 ID 已存在');
|
}
|
|
const alias = definition.alias.trim();
|
if (before.some((control) => parseTag(control.Tag) && (control.Alias || '').trim() === alias)) {
|
throw new Error('字段名称已存在');
|
}
|
|
await writeDefinition(definition);
|
|
const after = await readControls('插入后无法读取内容控件列表');
|
const afterCount = countTag(after, definition.tag);
|
debugBridge('field:after-insert', { afterCount, beforeCount, controlType: definition.controlType, tag: definition.tag });
|
if (afterCount !== beforeCount + 1) {
|
throw new Error('插入后 Tag 计数未增加');
|
}
|
|
return after;
|
}
|
|
async function writeDefinition(definition: FieldDefinition, lock?: 'contentLocked' | 'sdtContentLocked' | 'unlocked', signal?: AbortSignal): Promise<void> {
|
if (signal?.aborted) return;
|
const currentControlId = await executeMethod('GetCurrentContentControl');
|
if (signal?.aborted) return;
|
if (typeof currentControlId === 'string' && currentControlId) {
|
throw new Error('请将光标移到已有字段控件外再插入');
|
}
|
|
if (definition.controlType === 'text') {
|
const result = await executeMethod('AddContentControl', [
|
2,
|
{
|
Tag: definition.tag,
|
Alias: definition.alias,
|
Lock: lock === 'contentLocked' ? 0 : lock === 'sdtContentLocked' ? 1 : 3,
|
PlaceHolderText: definition.placeholder,
|
Appearance: 1,
|
Color: definition.color,
|
},
|
]);
|
if (signal?.aborted) return;
|
if (
|
!result ||
|
typeof result !== 'object' ||
|
typeof (result as Record<string, unknown>).InternalId !== 'string' ||
|
(result as Record<string, unknown>).Tag !== definition.tag
|
) {
|
throw new Error('编辑器未能在光标位置插入文本控件,请在目标位置单击出现文字光标后重试');
|
}
|
|
insertedTextDefinitions.set(definition.tag, definition);
|
verifyNativeControlTag = definition.tag;
|
if (definition.defaultValue) {
|
await writeTextDefaultWithNativeControl(definition.tag, definition.defaultValue);
|
if (signal?.aborted) return;
|
}
|
return;
|
}
|
|
const isDropdown = definition.controlType === 'dropdown';
|
await executeCommand(
|
insertTypedControlCommand,
|
'elnTemplateInsert',
|
{
|
controlType: definition.controlType,
|
tag: definition.tag,
|
alias: definition.alias,
|
placeholder: definition.placeholder,
|
dateFormat: 'dateFormat' in definition ? definition.dateFormat : undefined,
|
debug: import.meta.env.DEV,
|
defaultValue: isDropdown ? '' : definition.defaultValue,
|
defaultChecked: isDropdown ? false : definition.defaultChecked,
|
options: isDropdown ? definition.options : [],
|
selectedIndex: isDropdown ? definition.selectedIndex : -1,
|
color: definition.color,
|
lock,
|
},
|
true,
|
);
|
if (signal?.aborted) return;
|
|
if (lock === 'contentLocked') {
|
await updateControlSpec({
|
...bindingSpec(definition),
|
currentTag: definition.tag,
|
lock,
|
tag: definition.tag,
|
});
|
}
|
}
|
|
function bindingSpec(definition: FieldDefinition) {
|
return {
|
alias: definition.alias,
|
controlConfig: {
|
placeholder: definition.placeholder,
|
...('defaultValue' in definition ? { defaultValue: definition.defaultValue } : {}),
|
...('defaultChecked' in definition ? { defaultChecked: definition.defaultChecked } : {}),
|
...('dateFormat' in definition && definition.dateFormat ? { dateFormat: definition.dateFormat } : {}),
|
...('options' in definition ? { options: definition.options } : {}),
|
},
|
controlType: definition.controlType.toUpperCase(),
|
};
|
}
|
|
function definitionFromBinding(binding: TemplateBinding, currentText?: string): FieldDefinition {
|
const config = binding.controlConfig ?? {};
|
const alias = binding.controlAlias || binding.fieldCode;
|
const configuredPlaceholder = typeof config.placeholder === 'string' ? config.placeholder : '';
|
const placeholder = configuredPlaceholder.trim() || alias;
|
const rawOptions = Array.isArray(config.options) ? config.options : [];
|
const options = rawOptions.map((item) => {
|
if (typeof item === 'object' && item && 'display' in item && 'value' in item) return { display: String(item.display), value: String(item.value) };
|
return { display: String(item), value: String(item) };
|
});
|
const base = { fieldCode: binding.fieldCode, alias, tag: binding.controlTag, color: { R: 23, G: 109, B: 92 } };
|
if (binding.controlType === 'DROPDOWN') {
|
const value = currentText ?? String(config.defaultValue ?? '');
|
return {
|
...base,
|
controlType: 'dropdown',
|
placeholder,
|
options,
|
selectedIndex: options.findIndex((item) => item.display === value || item.value === value),
|
};
|
}
|
if (binding.controlType === 'CHECKBOX')
|
return {
|
...base,
|
controlType: 'checkbox',
|
placeholder: '',
|
defaultValue: '',
|
defaultChecked: currentText ? /^(?:true|1|☑|是)$/iu.test(currentText) : Boolean(config.defaultChecked),
|
};
|
if (binding.controlType === 'DATE')
|
return {
|
...base,
|
controlType: 'date',
|
dateFormat: typeof config.dateFormat === 'string' && config.dateFormat ? config.dateFormat : 'yyyy-MM-dd',
|
placeholder,
|
defaultValue: currentText ?? String(config.defaultValue ?? ''),
|
defaultChecked: false,
|
};
|
if (binding.controlType === 'MULTILINE')
|
return { ...base, controlType: 'multiline', placeholder, defaultValue: currentText ?? String(config.defaultValue ?? ''), defaultChecked: false };
|
return { ...base, controlType: 'text', placeholder, defaultValue: currentText ?? String(config.defaultValue ?? ''), defaultChecked: false };
|
}
|
|
function updateControlSpec(update: {
|
alias: string;
|
controlConfig: null | Record<string, unknown>;
|
controlType: string;
|
currentTag: string;
|
lock: 'contentLocked' | 'sdtContentLocked' | 'unlocked';
|
tag: string;
|
}): Promise<CommandResult> {
|
return executeCommand(updateControlSpecCommand, 'elnTemplateUpdateControls', { removals: [], updates: [update] });
|
}
|
|
async function insertBinding(binding: TemplateBinding, signal?: AbortSignal): Promise<void> {
|
if (signal?.aborted) return;
|
await writeDefinition(definitionFromBinding(binding), bindingLock(binding), signal);
|
}
|
|
async function insertReadOnlyBinding(binding: TemplateBinding, signal?: AbortSignal): Promise<void> {
|
if (signal?.aborted) return;
|
await writeDefinition(definitionFromBinding(binding), 'sdtContentLocked', signal);
|
}
|
|
async function insertReference(binding: TemplateBinding, primaryTag: string, signal?: AbortSignal): Promise<void> {
|
if (signal?.aborted) return;
|
const result = await executeCommand(readControlTextCommand, 'elnTemplateReadText', { tag: primaryTag });
|
if (signal?.aborted) return;
|
await writeDefinition(definitionFromBinding(binding, typeof result.text === 'string' ? result.text : ''), 'contentLocked', signal);
|
}
|
|
async function applyControlPlan(bindings: TemplateBinding[], desired: TemplateControlSnapshot[], signal?: AbortSignal): Promise<void> {
|
if (signal?.aborted) return;
|
const controls = await listControls();
|
if (signal?.aborted) return;
|
const desiredById = new Map(desired.map((item) => [item.bindingId, item]));
|
const alreadyAppliedIds = new Set<string>();
|
const appliedTargetTags = new Set<string>();
|
for (const binding of bindings.filter((item) => item.status === 'ACTIVE')) {
|
const target = desiredById.get(binding.id);
|
if (!target) continue;
|
const currentMatches = controls.filter((item) => item.Tag === binding.controlTag);
|
if (target.controlTag === binding.controlTag) {
|
if (currentMatches.length === 1 && controlMatchesTarget(binding, target, currentMatches[0]!)) alreadyAppliedIds.add(binding.id);
|
continue;
|
}
|
if (currentMatches.length > 0) continue;
|
const matches = controls.filter((item) => item.Tag === target.controlTag);
|
if (matches.length === 1 && controlMatchesTarget(binding, target, matches[0]!)) {
|
alreadyAppliedIds.add(binding.id);
|
appliedTargetTags.add(target.controlTag);
|
}
|
}
|
const removals: string[] = [];
|
const updates: Array<{
|
alias: string;
|
controlConfig: null | Record<string, unknown>;
|
controlType: string;
|
currentTag: string;
|
lock: 'contentLocked' | 'sdtContentLocked' | 'unlocked';
|
tag: string;
|
}> = [];
|
for (const binding of bindings.filter((item) => item.status === 'ACTIVE')) {
|
const target = desiredById.get(binding.id);
|
const control = controls.find((item) => item.Tag === binding.controlTag);
|
if (!target) {
|
if (control && !appliedTargetTags.has(binding.controlTag)) removals.push(binding.controlTag);
|
continue;
|
}
|
if (alreadyAppliedIds.has(binding.id)) continue;
|
if (!control) throw new Error(`待更新控件不存在:${binding.controlTag}`);
|
const targetType = (target.controlType || binding.controlType).toUpperCase();
|
if (typeof control.ControlType === 'string' && control.ControlType.toUpperCase() !== targetType) {
|
throw new Error('当前阶段不支持修改已有控件类型');
|
}
|
const currentPlaceholder = control.ControlConfig?.placeholder;
|
const targetPlaceholder = (target.controlConfig ?? binding.controlConfig)?.placeholder;
|
if (targetType !== 'CHECKBOX' && targetPlaceholder === '' && typeof currentPlaceholder === 'string' && currentPlaceholder !== '') {
|
throw new Error('当前编辑器不支持清空已有占位文本');
|
}
|
updates.push({
|
currentTag: binding.controlTag,
|
tag: target.controlTag,
|
alias: target.controlAlias || binding.controlAlias || binding.fieldCode,
|
controlType: targetType,
|
controlConfig: target.controlConfig ?? binding.controlConfig,
|
lock: bindingLock(binding, target.controlRole ?? binding.controlRole),
|
});
|
}
|
if (signal?.aborted) return;
|
if (removals.length > 1) throw new Error('当前阶段不支持一次删除多个控件');
|
if (updates.length || removals.length) await executeCommand(updateControlSpecCommand, 'elnTemplateUpdateControls', { removals, updates });
|
}
|
|
function snapshotControls(bindings: TemplateBinding[], controls: OfficeContentControl[], options: SnapshotControlOptions = {}): TemplateControlSnapshot[] {
|
return bindings.map((binding) => {
|
const matches = controls.filter((control) => control.Tag === binding.controlTag);
|
if (matches.length !== 1) throw new Error(`控件枚举数量异常:${binding.controlTag}`);
|
const control = matches[0]!;
|
if (
|
typeof control.Alias !== 'string' ||
|
!control.ControlConfig ||
|
typeof control.ControlRole !== 'string' ||
|
typeof control.ControlType !== 'string' ||
|
typeof control.Lock !== 'string'
|
) {
|
throw new Error(`控件枚举属性不完整:${binding.controlTag}`);
|
}
|
const actualType = snapshotControlType(binding.controlType, control.ControlType);
|
const actualConfig = normalizeExistingControlConfig(actualType, control.ControlRole, control.ControlConfig);
|
const expectedConfig = normalizeExistingControlConfig(binding.controlType, binding.controlRole, bindingConfigForSnapshot(binding));
|
const expectedLock = bindingLock(binding, control.ControlRole);
|
const lockMatches = control.Lock === expectedLock || (control.Lock === 'sdtContentLocked' && options.inheritedFormulaLockBindingIds?.has(binding.id));
|
if (
|
control.Alias !== (binding.controlAlias ?? '') ||
|
actualType !== binding.controlType ||
|
control.ControlRole !== binding.controlRole ||
|
!configsEqual(actualConfig, expectedConfig) ||
|
!lockMatches
|
) {
|
throw new Error(`控件属性与绑定不一致:${binding.controlTag}`);
|
}
|
return {
|
bindingId: binding.id,
|
controlTag: control.Tag!,
|
controlAlias: control.Alias,
|
controlType: actualType,
|
controlRole: control.ControlRole,
|
fieldVersionId: binding.fieldVersionId,
|
primaryBindingId: binding.primaryBindingId,
|
repeatGroupCode: binding.repeatGroupCode,
|
repeatPrototypeRowId: binding.repeatPrototypeRowId,
|
stepId: binding.stepId,
|
controlConfig: actualConfig,
|
};
|
});
|
}
|
|
async function selectControl(internalId: string, signal?: AbortSignal): Promise<void> {
|
if (!internalId) {
|
throw new Error('该内容控件没有可用的内部 ID');
|
}
|
if (signal?.aborted) return;
|
|
const controls = await listControls();
|
if (signal?.aborted) return;
|
if (!controls.some((control) => control.InternalId === internalId)) {
|
throw new Error('定位目标已不存在,请刷新字段列表');
|
}
|
|
const result = await executeMethod('SelectContentControl', [internalId]);
|
if (result === false) {
|
throw new Error('编辑器未能定位字段');
|
}
|
}
|
|
async function deleteControl(internalId: string): Promise<OfficeContentControl[]> {
|
if (!internalId) {
|
throw new Error('该内容控件没有可用的内部 ID');
|
}
|
|
const before = await listControls();
|
if (!before.some((control) => control.InternalId === internalId)) {
|
throw new Error('删除目标已不存在,请刷新字段列表');
|
}
|
|
if ((await executeMethod('RemoveContentControl', [internalId])) === false) {
|
throw new Error('编辑器未能移除字段控件');
|
}
|
|
const after = await readControls('解除后无法读取内容控件列表');
|
if (after.some((control) => control.InternalId === internalId)) {
|
throw new Error('解除后字段仍然存在');
|
}
|
return after;
|
}
|
|
function close(): void {
|
asc.plugin.executeCommand('close', '');
|
}
|
|
function getRuntimeOptions(): unknown {
|
return asc.plugin.info?.options;
|
}
|
|
function bindLifecycle(callbacks: LifecycleCallbacks): () => void {
|
return bindLifecycleHandlers(asc, callbacks);
|
}
|
|
return {
|
applyControlPlan,
|
bindLifecycle,
|
close,
|
deleteControl,
|
disableTrackRevisions,
|
getRuntimeOptions,
|
insertBinding,
|
insertReadOnlyBinding,
|
insertField,
|
insertReference,
|
isFormulaResultLocked,
|
listControls,
|
lockFormulaResult,
|
readSelectedText,
|
restoreFormulaResultLocks,
|
selectControl,
|
snapshotControls,
|
unlockFormulaResult,
|
};
|
}
|