Remove unused online editor modules

This commit is contained in:
2026-06-09 07:46:10 +08:00
parent 0303ba977e
commit d286bbec8f
219 changed files with 0 additions and 63146 deletions
-675
View File
@@ -1,675 +0,0 @@
import { requestClient } from '#/api/request';
const BASE = '/api/smart-table';
// ==================== Types ====================
export interface SmartTableItem {
id: string;
name: string;
icon: string;
type?: string;
parent_id?: string | null;
sort?: number;
description?: string;
active_view_id?: string;
sys_create_datetime?: string;
sys_update_datetime?: string;
}
export interface SmartFieldItem {
id: string;
table_id: string;
name: string;
type: string;
width: number;
visible: boolean;
required: boolean;
description?: string;
config: Record<string, any>;
sort: number;
sys_create_datetime?: string;
}
export interface SmartRecordItem {
id: string;
table_id: string;
values: Record<string, any>;
sys_create_datetime?: string;
sys_update_datetime?: string;
sys_creator_id?: string;
sys_modifier_id?: string;
}
export interface SmartViewItem {
id: string;
table_id: string;
name: string;
type: string;
config: Record<string, any>;
sort: number;
sys_create_datetime?: string;
}
export interface SmartTableFull extends SmartTableItem {
type?: string;
content?: any;
fields: SmartFieldItem[];
records: SmartRecordItem[];
views: SmartViewItem[];
record_total: number;
next_cursor: string | null;
has_more: boolean;
sys_creator_id?: string;
creator_name?: string;
creator_avatar?: string;
}
export interface CursorPaginatedRecords {
items: SmartRecordItem[];
total: number;
next_cursor: string | null;
has_more: boolean;
}
export interface RecordFilterParam {
field_id: string;
operator: string;
value?: any;
}
export interface RecordSortParam {
field_id: string;
direction: 'asc' | 'desc';
}
export interface RecordQueryParam {
filters?: RecordFilterParam[];
filter_logic?: 'and' | 'or';
sorts?: RecordSortParam[];
search?: string;
search_field_ids?: string[];
group_field_id?: string;
cursor?: string | null;
limit?: number;
}
export interface RecordGroupItem {
key: string;
label: string;
records: SmartRecordItem[];
}
export interface GroupedRecordsResponse {
groups: RecordGroupItem[];
total: number;
}
// ==================== Table API ====================
export function getTableListApi(wikiSpaceId?: string | null) {
const params: Record<string, any> = {};
if (wikiSpaceId) params.wiki_space_id = wikiSpaceId;
return requestClient.get<SmartTableItem[]>(`${BASE}/tables`, { params });
}
export function getTableFullApi(
tableId: string,
opts?: { filters?: RecordFilterParam[]; sorts?: RecordSortParam[]; search?: string; filter_logic?: string },
) {
const params: Record<string, any> = {};
if (opts?.filters?.length) params.filters = JSON.stringify(opts.filters);
if (opts?.sorts?.length) params.sorts = JSON.stringify(opts.sorts);
if (opts?.search) params.search = opts.search;
if (opts?.filter_logic) params.filter_logic = opts.filter_logic;
return requestClient.get<SmartTableFull>(`${BASE}/tables/${tableId}/full`, { params });
}
export function createTableApi(data: { name: string; icon?: string; description?: string; type?: string; content?: any; parent_id?: string | null; wiki_space_id?: string | null }) {
return requestClient.post<SmartTableItem>(`${BASE}/tables`, data);
}
export function updateTableApi(tableId: string, data: Partial<SmartTableItem>) {
return requestClient.put<SmartTableItem>(`${BASE}/tables/${tableId}`, data);
}
export function updateDocumentContentApi(tableId: string, content: any) {
return requestClient.patch(`${BASE}/tables/${tableId}/content`, { content });
}
export function exportDocumentPdfApi(tableId: string, html: string, title: string) {
return requestClient.post<Blob>(`${BASE}/tables/${tableId}/export-pdf`, { html, title }, {
responseType: 'blob',
});
}
export function deleteTableApi(tableId: string) {
return requestClient.delete(`${BASE}/tables/${tableId}`);
}
export function moveTableApi(tableId: string, parentId: string | null, afterId?: string | null) {
return requestClient.put<SmartTableItem>(`${BASE}/tables/${tableId}/move`, {
parent_id: parentId,
after_id: afterId ?? null,
});
}
// ==================== Field API ====================
export function getFieldListApi(tableId: string) {
return requestClient.get<SmartFieldItem[]>(`${BASE}/tables/${tableId}/fields`);
}
export function createFieldApi(tableId: string, data: Omit<SmartFieldItem, 'id' | 'table_id' | 'sys_create_datetime'>) {
return requestClient.post<SmartFieldItem>(`${BASE}/tables/${tableId}/fields`, data);
}
export function updateFieldApi(fieldId: string, data: Partial<SmartFieldItem>) {
return requestClient.put<SmartFieldItem>(`${BASE}/fields/${fieldId}`, data);
}
export function deleteFieldApi(fieldId: string) {
return requestClient.delete(`${BASE}/fields/${fieldId}`);
}
export function reorderFieldsApi(tableId: string, fieldIds: string[]) {
return requestClient.put(`${BASE}/tables/${tableId}/fields/reorder`, { field_ids: fieldIds });
}
// ==================== Record API ====================
export function getRecordListApi(
tableId: string,
cursor?: string | null,
limit = 200,
opts?: { filters?: RecordFilterParam[]; sorts?: RecordSortParam[]; search?: string; filter_logic?: string },
) {
const params: Record<string, any> = { cursor: cursor ?? undefined, limit };
if (opts?.filters?.length) params.filters = JSON.stringify(opts.filters);
if (opts?.sorts?.length) params.sorts = JSON.stringify(opts.sorts);
if (opts?.search) params.search = opts.search;
if (opts?.filter_logic) params.filter_logic = opts.filter_logic;
return requestClient.get<CursorPaginatedRecords>(
`${BASE}/tables/${tableId}/records`,
{ params },
);
}
export function queryRecordsApi(tableId: string, query: RecordQueryParam) {
return requestClient.post<CursorPaginatedRecords | GroupedRecordsResponse>(
`${BASE}/tables/${tableId}/records/query`,
query,
);
}
export function reorderRecordsApi(tableId: string, recordIds: string[]) {
return requestClient.put(`${BASE}/tables/${tableId}/records/reorder`, { record_ids: recordIds });
}
export function createRecordApi(tableId: string, values: Record<string, any> = {}) {
return requestClient.post<SmartRecordItem>(`${BASE}/tables/${tableId}/records`, { table_id: tableId, values });
}
export function updateRecordApi(recordId: string, values: Record<string, any>) {
return requestClient.put<SmartRecordItem>(`${BASE}/records/${recordId}`, { values });
}
export function updateCellApi(recordId: string, fieldId: string, value: any) {
return requestClient.patch<SmartRecordItem>(`${BASE}/records/${recordId}/cells`, {
field_id: fieldId,
value,
});
}
export function batchUpdateCellsApi(recordId: string, cells: Record<string, any>) {
return requestClient.patch<SmartRecordItem>(`${BASE}/records/${recordId}/cells/batch`, { cells });
}
export function batchUpdateMultiRecordCellsApi(
tableId: string,
updates: Array<{ record_id: string; cells: Record<string, any> }>,
) {
return requestClient.patch(`${BASE}/tables/${tableId}/records/batch-cells`, { updates });
}
export function deleteRecordApi(recordId: string) {
return requestClient.delete(`${BASE}/records/${recordId}`);
}
export function batchDeleteRecordsApi(tableId: string, ids: string[]) {
return requestClient.post(`${BASE}/tables/${tableId}/records/batch-delete`, { ids });
}
// ==================== Trash / Recycle Bin ====================
export interface TrashRecordItem {
id: string;
table_id: string;
values: Record<string, any>;
sys_create_datetime?: string;
sys_update_datetime?: string;
sys_creator_id?: string;
}
export function getTrashRecordsApi(tableId: string, page = 1, pageSize = 50) {
return requestClient.get<{ items: TrashRecordItem[]; total: number }>(
`${BASE}/tables/${tableId}/trash`,
{ params: { page, page_size: pageSize } },
);
}
export function restoreTrashRecordsApi(tableId: string, ids: string[]) {
return requestClient.post(`${BASE}/tables/${tableId}/trash/restore`, { ids });
}
export function permanentDeleteTrashRecordApi(tableId: string, recordId: string) {
return requestClient.delete(`${BASE}/tables/${tableId}/trash/${recordId}`);
}
export function emptyTrashApi(tableId: string) {
return requestClient.delete(`${BASE}/tables/${tableId}/trash`);
}
export function exportTableApi(tableId: string, format: 'csv' | 'xlsx' = 'csv') {
return requestClient.get(`${BASE}/tables/${tableId}/export`, {
params: { format },
responseType: 'blob',
});
}
export function importTableApi(tableId: string, file: File) {
const formData = new FormData();
formData.append('file', file);
return requestClient.post(`${BASE}/tables/${tableId}/import`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
}
export interface RecordSearchResultItem {
id: string;
title: string;
}
export function searchRecordsApi(tableId: string, keyword: string = '', limit: number = 20) {
return requestClient.post<RecordSearchResultItem[]>(
`${BASE}/tables/${tableId}/records/search`,
{ keyword, limit },
);
}
// ==================== Summary API ====================
export interface SummaryResult {
summaries: Record<string, any>;
total_count: number;
}
export function getSummaryApi(
tableId: string,
data: {
aggregations: Record<string, string>;
filters?: RecordFilterParam[];
filter_logic?: string;
search?: string;
},
) {
return requestClient.post<SummaryResult>(`${BASE}/tables/${tableId}/summary`, data);
}
// ==================== Comment API ====================
export interface CommentItem {
id: string;
record_id: string;
user_id: string;
content: string;
mentions: string[];
parent_id: string | null;
sys_create_datetime?: string;
sys_update_datetime?: string;
user_name?: string;
user_avatar?: string;
replies: CommentItem[];
}
export function getCommentsApi(recordId: string) {
return requestClient.get<CommentItem[]>(`${BASE}/records/${recordId}/comments`);
}
export function createCommentApi(recordId: string, data: { content: string; mentions?: string[]; parent_id?: string }) {
return requestClient.post<CommentItem>(`${BASE}/records/${recordId}/comments`, data);
}
export function updateCommentApi(commentId: string, data: { content: string; mentions?: string[] }) {
return requestClient.put<CommentItem>(`${BASE}/comments/${commentId}`, data);
}
export function deleteCommentApi(commentId: string) {
return requestClient.delete(`${BASE}/comments/${commentId}`);
}
// ==================== View API ====================
export function getViewListApi(tableId: string) {
return requestClient.get<SmartViewItem[]>(`${BASE}/tables/${tableId}/views`);
}
export function createViewApi(tableId: string, data: { name: string; type: string; config?: Record<string, any> }) {
return requestClient.post<SmartViewItem>(`${BASE}/tables/${tableId}/views`, { ...data, table_id: tableId });
}
export function updateViewApi(viewId: string, data: Partial<SmartViewItem>) {
return requestClient.put<SmartViewItem>(`${BASE}/views/${viewId}`, data);
}
export function deleteViewApi(viewId: string) {
return requestClient.delete(`${BASE}/views/${viewId}`);
}
// ==================== Permission API ====================
export interface MyPermission {
role_type: string;
role_name: string;
capabilities: Record<string, boolean>;
field_permissions: Record<string, string>;
row_view_mode: string;
row_edit_mode: string;
}
export interface TableRole {
id: string;
table_id: string | null;
name: string;
role_type: string;
capabilities: Record<string, boolean>;
is_system: boolean;
sys_create_datetime?: string;
}
export interface Collaborator {
id: string;
table_id: string;
subject_type: string;
subject_id: string;
role_id: string;
role_name?: string;
role_type?: string;
subject_name?: string;
subject_avatar?: string;
sys_create_datetime?: string;
}
export interface FieldPermItem {
field_id: string;
access: string;
}
export interface FieldPermMatrix {
role_id: string;
role_name: string;
role_type: string;
fields: FieldPermItem[];
}
export interface RowRule {
id: string;
table_id: string;
role_id: string;
rule_type: string;
mode: string;
conditions: Record<string, any>[];
}
export function getMyPermissionApi(tableId: string) {
return requestClient.get<MyPermission>(`${BASE}/tables/${tableId}/my-permission`);
}
export function getRolesApi(tableId: string) {
return requestClient.get<TableRole[]>(`${BASE}/tables/${tableId}/roles`);
}
export function createRoleApi(tableId: string, data: { name: string; capabilities: Record<string, boolean> }) {
return requestClient.post<TableRole>(`${BASE}/tables/${tableId}/roles`, data);
}
export function updateRoleApi(tableId: string, roleId: string, data: { name?: string; capabilities?: Record<string, boolean> }) {
return requestClient.put<TableRole>(`${BASE}/tables/${tableId}/roles/${roleId}`, data);
}
export function deleteRoleApi(tableId: string, roleId: string) {
return requestClient.delete(`${BASE}/tables/${tableId}/roles/${roleId}`);
}
export function getCollaboratorsApi(tableId: string) {
return requestClient.get<Collaborator[]>(`${BASE}/tables/${tableId}/collaborators`);
}
export function addCollaboratorApi(tableId: string, data: { subject_type: string; subject_id: string; role_id: string }) {
return requestClient.post<Collaborator>(`${BASE}/tables/${tableId}/collaborators`, data);
}
export function updateCollaboratorApi(tableId: string, collabId: string, data: { role_id: string }) {
return requestClient.put<Collaborator>(`${BASE}/tables/${tableId}/collaborators/${collabId}`, data);
}
export function removeCollaboratorApi(tableId: string, collabId: string) {
return requestClient.delete(`${BASE}/tables/${tableId}/collaborators/${collabId}`);
}
export function getFieldPermissionsApi(tableId: string) {
return requestClient.get<FieldPermMatrix[]>(`${BASE}/tables/${tableId}/field-permissions`);
}
export function updateFieldPermissionsApi(tableId: string, data: { role_id: string; permissions: FieldPermItem[] }) {
return requestClient.put(`${BASE}/tables/${tableId}/field-permissions`, data);
}
export function getRowRulesApi(tableId: string) {
return requestClient.get<RowRule[]>(`${BASE}/tables/${tableId}/row-rules`);
}
export function updateRowRuleApi(tableId: string, data: { role_id: string; rule_type: string; mode: string; conditions: Record<string, any>[] }) {
return requestClient.put<RowRule>(`${BASE}/tables/${tableId}/row-rules`, data);
}
// ==================== Document Version API ====================
export interface DocumentVersionItem {
id: string;
document_id: string;
version: number;
title?: string;
change_summary?: string;
content_size: number;
sys_create_datetime?: string;
sys_creator_id?: string;
creator_name?: string;
creator_avatar?: string;
}
export interface DocumentVersionDetail extends DocumentVersionItem {
content: Record<string, any>;
}
export interface DocumentVersionCompare {
version_from: DocumentVersionDetail;
version_to: DocumentVersionDetail;
}
export function getDocumentVersionsApi(tableId: string, page = 1, pageSize = 20) {
return requestClient.get<{ items: DocumentVersionItem[]; total: number }>(
`${BASE}/tables/${tableId}/versions`,
{ params: { page, pageSize } },
);
}
export function getDocumentVersionDetailApi(versionId: string) {
return requestClient.get<DocumentVersionDetail>(`${BASE}/versions/${versionId}`);
}
export function createDocumentVersionApi(tableId: string, changeSummary?: string) {
return requestClient.post<DocumentVersionItem>(
`${BASE}/tables/${tableId}/versions`,
{ change_summary: changeSummary },
);
}
export function restoreDocumentVersionApi(tableId: string, versionId: string) {
return requestClient.post(`${BASE}/tables/${tableId}/versions/${versionId}/restore`);
}
export function compareDocumentVersionsApi(tableId: string, fromId: string, toId: string) {
return requestClient.get<DocumentVersionCompare>(
`${BASE}/tables/${tableId}/versions/compare`,
{ params: { from: fromId, to: toId } },
);
}
export function deleteDocumentVersionApi(versionId: string) {
return requestClient.delete(`${BASE}/versions/${versionId}`);
}
// ==================== Document Template API ====================
export interface DocumentTemplateItem {
id: string;
name: string;
description?: string;
icon: string;
category: string;
preview_image?: string;
is_system: boolean;
use_count: number;
sys_create_datetime?: string;
sys_update_datetime?: string;
sys_creator_id?: string;
creator_name?: string;
}
export interface DocumentTemplateDetail extends DocumentTemplateItem {
content: Record<string, any>;
}
export function getDocumentTemplatesApi(params?: { category?: string; keyword?: string; page?: number; pageSize?: number }) {
return requestClient.get<{ items: DocumentTemplateItem[]; total: number }>(
`${BASE}/document-templates`,
{ params },
);
}
export function getDocumentTemplateCategoriesApi() {
return requestClient.get<string[]>(`${BASE}/document-templates/categories`);
}
export function getDocumentTemplateDetailApi(templateId: string) {
return requestClient.get<DocumentTemplateDetail>(`${BASE}/document-templates/${templateId}`);
}
export function createDocumentTemplateApi(data: {
name: string;
description?: string;
icon?: string;
category?: string;
content: Record<string, any>;
preview_image?: string;
}) {
return requestClient.post<DocumentTemplateItem>(`${BASE}/document-templates`, data);
}
export function createTemplateFromDocumentApi(documentId: string, data: {
name: string;
description?: string;
category?: string;
content: Record<string, any>;
}) {
return requestClient.post<DocumentTemplateItem>(
`${BASE}/document-templates/from-document/${documentId}`,
data,
);
}
export function updateDocumentTemplateApi(templateId: string, data: {
name?: string;
description?: string;
icon?: string;
category?: string;
content?: Record<string, any>;
preview_image?: string;
}) {
return requestClient.put<DocumentTemplateItem>(`${BASE}/document-templates/${templateId}`, data);
}
export function deleteDocumentTemplateApi(templateId: string) {
return requestClient.delete(`${BASE}/document-templates/${templateId}`);
}
export function useDocumentTemplateApi(templateId: string) {
return requestClient.post(`${BASE}/document-templates/${templateId}/use`);
}
// ==================== Wiki Space API ====================
export interface WikiSpaceItem {
id: string;
name: string;
icon: string;
avatar?: string | null;
description?: string;
cover?: string;
category: string;
visibility: string;
sort: number;
document_count?: number;
sys_create_datetime?: string;
sys_update_datetime?: string;
sys_creator_id?: string;
creator_name?: string;
}
export interface WikiSpaceDetail extends WikiSpaceItem {
documents: SmartTableItem[];
}
export function getWikiSpacesApi() {
return requestClient.get<WikiSpaceItem[]>(`${BASE}/wiki-spaces`);
}
export function getWikiSpaceDetailApi(spaceId: string) {
return requestClient.get<WikiSpaceDetail>(`${BASE}/wiki-spaces/${spaceId}`);
}
export function createWikiSpaceApi(data: {
name: string;
icon?: string;
avatar?: string | null;
description?: string;
cover?: string;
category?: string;
visibility?: string;
}) {
return requestClient.post<WikiSpaceItem>(`${BASE}/wiki-spaces`, data);
}
export function updateWikiSpaceApi(spaceId: string, data: Partial<WikiSpaceItem>) {
return requestClient.put<WikiSpaceItem>(`${BASE}/wiki-spaces/${spaceId}`, data);
}
export function deleteWikiSpaceApi(spaceId: string) {
return requestClient.delete(`${BASE}/wiki-spaces/${spaceId}`);
}
export function getWikiSpaceDocumentsApi(spaceId: string) {
return requestClient.get<SmartTableItem[]>(`${BASE}/wiki-spaces/${spaceId}/documents`);
}
export function createWikiDocumentApi(spaceId: string, data: {
name: string;
parent_id?: string | null;
content?: any;
}) {
return requestClient.post<SmartTableItem>(
`${BASE}/wiki-spaces/${spaceId}/documents`,
{ ...data, type: 'document', icon: 'FileText' },
);
}
@@ -1,981 +0,0 @@
<script setup lang="ts">
import type { PartyType } from '../store/contractDesignStore';
import { ref, watch } from 'vue';
import { Settings } from '@vben/icons';
import { $t } from '@vben/locales';
import {
ElButton,
ElColorPicker,
ElDivider,
ElForm,
ElFormItem,
ElIcon,
ElInput,
ElInputNumber,
ElOption,
ElRadioButton,
ElRadioGroup,
ElScrollbar,
ElSelect,
ElSwitch,
ElTabPane,
ElTabs,
} from 'element-plus';
import { storeToRefs } from 'pinia';
import {
predefinedVariables,
useContractDesignStore,
} from '../store/contractDesignStore';
const store = useContractDesignStore();
const { activeElement, templateConfig } = storeToRefs(store);
const activeTab = ref('element');
// 当前元素的本地副本
const localProps = ref<Record<string, any>>({});
const localSignature = ref<any>({});
const localTableColumns = ref<any[]>([]);
// 监听选中元素变化
watch(
activeElement,
(element) => {
if (element) {
localProps.value = { ...element.props };
localSignature.value = element.signature ? { ...element.signature } : {};
localTableColumns.value = element.tableColumns
? [...element.tableColumns]
: [];
} else {
localProps.value = {};
localSignature.value = {};
localTableColumns.value = [];
}
},
{ immediate: true, deep: true },
);
// 更新属性
const updateProps = (key: string, value: any) => {
if (activeElement.value) {
localProps.value[key] = value;
store.updateElementProps(activeElement.value.id, { [key]: value });
}
};
// 更新签名配置
const updateSignature = (key: string, value: any) => {
if (activeElement.value) {
localSignature.value[key] = value;
store.updateElement(activeElement.value.id, {
signature: { ...localSignature.value },
});
}
};
// 更新表格列
const updateTableColumns = () => {
if (activeElement.value) {
store.updateElement(activeElement.value.id, {
tableColumns: [...localTableColumns.value],
});
}
};
// 添加表格列
const addTableColumn = () => {
const key = `col${localTableColumns.value.length + 1}`;
localTableColumns.value.push({
key,
title: `${localTableColumns.value.length + 1}`,
width: 100,
align: 'center',
});
updateTableColumns();
};
// 删除表格列
const removeTableColumn = (index: number) => {
localTableColumns.value.splice(index, 1);
updateTableColumns();
};
// 添加表格行
const addTableRow = () => {
if (activeElement.value && activeElement.value.tableData) {
const newRow: Record<string, any> = {};
localTableColumns.value.forEach((col) => {
newRow[col.key] = '';
});
activeElement.value.tableData.push(newRow);
store.recordSnapshot();
}
};
// 签署方选项
const partyOptions: { label: string; value: PartyType }[] = [
{ label: $t('contract-design.partyA'), value: 'party_a' },
{ label: $t('contract-design.partyB'), value: 'party_b' },
{ label: $t('contract-design.partyC'), value: 'party_c' },
{ label: $t('contract-design.witness'), value: 'witness' },
];
// 元素类型标签
const typeLabels: Record<string, string> = {
title: $t('contract-design.typeTitle'),
paragraph: $t('contract-design.typeParagraph'),
'rich-text': $t('contract-design.typeRichText'),
variable: $t('contract-design.typeVariable'),
table: $t('contract-design.typeTable'),
'signature-zone': $t('contract-design.typeSignatureZone'),
'seal-zone': $t('contract-design.typeSealZone'),
'date-zone': $t('contract-design.typeDateZone'),
divider: $t('contract-design.typeDivider'),
'page-break': $t('contract-design.typePageBreak'),
image: $t('contract-design.typeImage'),
};
// 更新模板配置
const updateTemplateField = (field: string, value: any) => {
store.updateTemplateConfig({ [field]: value });
};
// 获取变量信息
const getVariableInfo = (code: string) => {
return predefinedVariables.find((v) => v.code === code);
};
// 变量类型标签
const variableTypeLabels: Record<string, string> = {
text: $t('contract-design.text'),
number: $t('contract-design.number'),
date: $t('contract-design.date'),
money: $t('contract-design.money'),
select: $t('contract-design.select'),
};
// 检查变量是否已使用
const isVariableUsed = (code: string) => {
return templateConfig.value.variables.some((v) => v.code === code);
};
// 添加变量到模板
const addVariableToTemplate = (variable: (typeof predefinedVariables)[0]) => {
if (!isVariableUsed(variable.code)) {
store.addVariable({ ...variable });
}
};
// 更新页边距
const updateMargin = (side: string, value: number) => {
const margin = { ...templateConfig.value.pageMargin, [side]: value };
store.updateTemplateConfig({ pageMargin: margin });
};
</script>
<template>
<div
class="attribute-panel flex h-full w-80 flex-col rounded border border-[var(--el-border-color)] bg-[var(--el-bg-color)]"
>
<!-- 标题 -->
<div
class="flex flex-shrink-0 items-center gap-2 border-b border-[var(--el-border-color)] px-4 py-3"
>
<ElIcon :size="16" class="text-[var(--el-color-primary)]">
<Settings />
</ElIcon>
<span class="text-sm font-bold">{{
$t('contract-design.attributePanel')
}}</span>
</div>
<!-- 标签页 -->
<ElTabs v-model="activeTab" class="flex-1 overflow-hidden">
<!-- 元素属性 -->
<ElTabPane
:label="$t('contract-design.elementPanel')"
name="element"
class="h-full"
>
<ElScrollbar class="h-full">
<div class="p-4">
<!-- 无选中元素 -->
<div
v-if="!activeElement"
class="flex h-40 items-center justify-center text-sm text-[var(--el-text-color-placeholder)]"
>
{{ $t('contract-design.selectElement') }}
</div>
<!-- 元素属性表单 -->
<ElForm v-else label-position="top" label-width="auto" size="small">
<!-- 元素类型 -->
<div
class="mb-4 rounded bg-[var(--el-fill-color-light)] p-2 text-center text-sm"
>
{{ typeLabels[activeElement.type] || activeElement.type }}
</div>
<!-- 标题属性 -->
<template v-if="activeElement.type === 'title'">
<ElFormItem :label="$t('contract-design.titleContent')">
<ElInput
v-model="localProps.content"
@change="updateProps('content', localProps.content)"
/>
</ElFormItem>
<ElFormItem :label="$t('contract-design.titleLevel')">
<ElRadioGroup
v-model="localProps.level"
@change="updateProps('level', localProps.level)"
>
<ElRadioButton :value="1">H1</ElRadioButton>
<ElRadioButton :value="2">H2</ElRadioButton>
<ElRadioButton :value="3">H3</ElRadioButton>
</ElRadioGroup>
</ElFormItem>
<ElFormItem :label="$t('contract-design.alignment')">
<ElRadioGroup
v-model="localProps.align"
@change="updateProps('align', localProps.align)"
>
<ElRadioButton value="left">
{{ $t('contract-design.left') }}
</ElRadioButton>
<ElRadioButton value="center">
{{ $t('contract-design.center') }}
</ElRadioButton>
<ElRadioButton value="right">
{{ $t('contract-design.right') }}
</ElRadioButton>
</ElRadioGroup>
</ElFormItem>
<ElFormItem :label="$t('contract-design.fontSize')">
<ElInputNumber
v-model="localProps.fontSize"
:min="12"
:max="48"
@change="updateProps('fontSize', localProps.fontSize)"
/>
</ElFormItem>
<ElFormItem :label="$t('contract-design.fontWeight')">
<ElSelect
v-model="localProps.fontWeight"
@change="updateProps('fontWeight', localProps.fontWeight)"
>
<ElOption
:label="$t('contract-design.normal')"
value="normal"
/>
<ElOption
:label="$t('contract-design.bold')"
value="bold"
/>
</ElSelect>
</ElFormItem>
</template>
<!-- 段落属性 -->
<template v-else-if="activeElement.type === 'paragraph'">
<ElFormItem :label="$t('contract-design.paragraphContent')">
<ElInput
v-model="localProps.content"
type="textarea"
:rows="4"
@change="updateProps('content', localProps.content)"
/>
</ElFormItem>
<ElFormItem :label="$t('contract-design.alignment')">
<ElRadioGroup
v-model="localProps.align"
@change="updateProps('align', localProps.align)"
>
<ElRadioButton value="left">
{{ $t('contract-design.left') }}
</ElRadioButton>
<ElRadioButton value="center">
{{ $t('contract-design.center') }}
</ElRadioButton>
<ElRadioButton value="right">
{{ $t('contract-design.right') }}
</ElRadioButton>
<ElRadioButton value="justify">
{{ $t('contract-design.justify') }}
</ElRadioButton>
</ElRadioGroup>
</ElFormItem>
<ElFormItem :label="$t('contract-design.fontSize')">
<ElInputNumber
v-model="localProps.fontSize"
:min="12"
:max="24"
@change="updateProps('fontSize', localProps.fontSize)"
/>
</ElFormItem>
<ElFormItem :label="$t('contract-design.lineHeight')">
<ElInputNumber
v-model="localProps.lineHeight"
:min="1"
:max="3"
:step="0.1"
:precision="1"
@change="updateProps('lineHeight', localProps.lineHeight)"
/>
</ElFormItem>
<ElFormItem :label="$t('contract-design.indent')">
<ElInputNumber
v-model="localProps.indent"
:min="0"
:max="8"
@change="updateProps('indent', localProps.indent)"
/>
</ElFormItem>
</template>
<!-- 变量属性 -->
<template v-else-if="activeElement.type === 'variable'">
<ElFormItem :label="$t('contract-design.selectVariable')">
<ElSelect
v-model="localProps.variableCode"
filterable
:placeholder="$t('contract-design.placeholderVariable')"
@change="
updateProps('variableCode', localProps.variableCode)
"
>
<ElOption
v-for="v in predefinedVariables"
:key="v.code"
:label="`${v.name} (${v.code})`"
:value="v.code"
/>
</ElSelect>
</ElFormItem>
<!-- 显示选中变量的信息 -->
<div
v-if="localProps.variableCode"
class="mb-4 rounded bg-[var(--el-fill-color-light)] p-3"
>
<div
class="mb-1 text-xs font-bold text-[var(--el-text-color-primary)]"
>
{{ getVariableInfo(localProps.variableCode)?.name }}
</div>
<div class="flex flex-wrap gap-2 text-xs">
<span
class="rounded bg-[var(--el-color-primary-light-9)] px-1.5 py-0.5 text-[var(--el-color-primary)]"
>
{{ getVariableInfo(localProps.variableCode)?.type }}
</span>
<span
v-if="getVariableInfo(localProps.variableCode)?.required"
class="rounded bg-[var(--el-color-danger-light-9)] px-1.5 py-0.5 text-[var(--el-color-danger)]"
>
{{ $t('contract-design.required') }}
</span>
<span
v-else
class="rounded bg-[var(--el-fill-color)] px-1.5 py-0.5 text-[var(--el-text-color-secondary)]"
>
{{ $t('contract-design.optional') }}
</span>
</div>
<div
class="mt-1 text-xs text-[var(--el-text-color-placeholder)]"
>
{{ $t('contract-design.codeLabel') }}:
{{ localProps.variableCode }}
</div>
</div>
<ElFormItem :label="$t('contract-design.placeholderText')">
<ElInput
v-model="localProps.placeholder"
:placeholder="$t('contract-design.notFilledText')"
@change="updateProps('placeholder', localProps.placeholder)"
/>
</ElFormItem>
<ElFormItem :label="$t('contract-design.minWidth')">
<ElInputNumber
v-model="localProps.minWidth"
:min="50"
:max="500"
@change="updateProps('minWidth', localProps.minWidth)"
/>
</ElFormItem>
<ElFormItem :label="$t('contract-design.showUnderline')">
<ElSwitch
v-model="localProps.underline"
@change="updateProps('underline', localProps.underline)"
/>
</ElFormItem>
<ElDivider />
<div
class="rounded bg-[var(--el-color-info-light-9)] p-3 text-xs text-[var(--el-text-color-secondary)]"
>
<div class="mb-1 font-bold">
{{ $t('contract-design.usageInstructions') }}
</div>
<div>
{{ $t('contract-design.variableUsageDesc') }}
</div>
</div>
</template>
<!-- 表格属性 -->
<template v-else-if="activeElement.type === 'table'">
<ElFormItem :label="$t('contract-design.showBorder')">
<ElSwitch
v-model="localProps.bordered"
@change="updateProps('bordered', localProps.bordered)"
/>
</ElFormItem>
<ElFormItem :label="$t('contract-design.headerBgColor')">
<ElColorPicker
v-model="localProps.headerBgColor"
@change="
updateProps('headerBgColor', localProps.headerBgColor)
"
/>
</ElFormItem>
<ElDivider>{{ $t('contract-design.tableColumns') }}</ElDivider>
<div
v-for="(col, index) in localTableColumns"
:key="index"
class="mb-2 rounded border border-[var(--el-border-color)] p-2"
>
<div class="mb-2 flex items-center justify-between">
<span class="text-xs font-bold"
>{{ $t('contract-design.column') }} {{ index + 1 }}</span
>
<ElButton
type="danger"
text
size="small"
@click="removeTableColumn(index)"
>
{{ $t('contract-design.delete') }}
</ElButton>
</div>
<ElInput
v-model="col.title"
:placeholder="$t('contract-design.columnTitle')"
size="small"
class="mb-2"
@change="updateTableColumns"
/>
<div class="flex gap-2">
<ElInputNumber
v-model="col.width"
:min="50"
:max="500"
size="small"
:placeholder="$t('contract-design.width')"
@change="updateTableColumns"
/>
<ElSelect
v-model="col.align"
size="small"
@change="updateTableColumns"
>
<ElOption
:label="$t('contract-design.left')"
value="left"
/>
<ElOption
:label="$t('contract-design.center')"
value="center"
/>
<ElOption
:label="$t('contract-design.right')"
value="right"
/>
</ElSelect>
</div>
</div>
<ElButton size="small" @click="addTableColumn">
{{ $t('contract-design.addColumn') }}
</ElButton>
<ElButton size="small" class="ml-2" @click="addTableRow">
{{ $t('contract-design.addRow') }}
</ElButton>
</template>
<!-- 签名区属性 -->
<template
v-else-if="
activeElement.type === 'signature-zone' ||
activeElement.type === 'seal-zone'
"
>
<ElFormItem :label="$t('contract-design.signatory')">
<ElSelect
v-model="localSignature.partyType"
@change="
updateSignature('partyType', localSignature.partyType)
"
>
<ElOption
v-for="opt in partyOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</ElSelect>
</ElFormItem>
<ElFormItem :label="$t('contract-design.labelText')">
<ElInput
v-model="localSignature.partyLabel"
@change="
updateSignature('partyLabel', localSignature.partyLabel)
"
/>
</ElFormItem>
<ElFormItem :label="$t('contract-design.alignment')">
<ElRadioGroup
v-model="localProps.align"
@change="updateProps('align', localProps.align)"
>
<ElRadioButton value="left">
{{ $t('contract-design.alignLeft') }}
</ElRadioButton>
<ElRadioButton value="center">
{{ $t('contract-design.alignCenter') }}
</ElRadioButton>
<ElRadioButton value="right">
{{ $t('contract-design.alignRight') }}
</ElRadioButton>
</ElRadioGroup>
</ElFormItem>
<ElFormItem :label="$t('contract-design.width')">
<ElInputNumber
v-model="localProps.width"
:min="100"
:max="400"
@change="updateProps('width', localProps.width)"
/>
</ElFormItem>
<ElFormItem :label="$t('contract-design.height')">
<ElInputNumber
v-model="localProps.height"
:min="60"
:max="200"
@change="updateProps('height', localProps.height)"
/>
</ElFormItem>
<ElFormItem :label="$t('contract-design.showBorder')">
<ElSwitch
v-model="localProps.showBorder"
@change="updateProps('showBorder', localProps.showBorder)"
/>
</ElFormItem>
<ElFormItem :label="$t('contract-design.showLabel')">
<ElSwitch
v-model="localProps.showLabel"
@change="updateProps('showLabel', localProps.showLabel)"
/>
</ElFormItem>
<ElFormItem :label="$t('contract-design.showDate')">
<ElSwitch
v-model="localSignature.showDate"
@change="
updateSignature('showDate', localSignature.showDate)
"
/>
</ElFormItem>
<ElFormItem :label="$t('contract-design.mustSign')">
<ElSwitch
v-model="localSignature.required"
@change="
updateSignature('required', localSignature.required)
"
/>
</ElFormItem>
</template>
<!-- 日期区属性 -->
<template v-else-if="activeElement.type === 'date-zone'">
<ElFormItem :label="$t('contract-design.label')">
<ElInput
v-model="localProps.label"
@change="updateProps('label', localProps.label)"
/>
</ElFormItem>
<ElFormItem :label="$t('contract-design.alignment')">
<ElRadioGroup
v-model="localProps.align"
@change="updateProps('align', localProps.align)"
>
<ElRadioButton value="left">
{{ $t('contract-design.alignLeft') }}
</ElRadioButton>
<ElRadioButton value="center">
{{ $t('contract-design.alignCenter') }}
</ElRadioButton>
<ElRadioButton value="right">
{{ $t('contract-design.alignRight') }}
</ElRadioButton>
</ElRadioGroup>
</ElFormItem>
<ElFormItem :label="$t('contract-design.dateFormat')">
<ElSelect
v-model="localProps.format"
@change="updateProps('format', localProps.format)"
>
<ElOption label="YYYY年MM月DD日" value="YYYY年MM月DD日" />
<ElOption label="YYYY-MM-DD" value="YYYY-MM-DD" />
<ElOption label="YYYY/MM/DD" value="YYYY/MM/DD" />
</ElSelect>
</ElFormItem>
<ElFormItem :label="$t('contract-design.showUnderline')">
<ElSwitch
v-model="localProps.showUnderline"
@change="
updateProps('showUnderline', localProps.showUnderline)
"
/>
</ElFormItem>
</template>
<!-- 分割线属性 -->
<template v-else-if="activeElement.type === 'divider'">
<ElFormItem :label="$t('contract-design.lineStyle')">
<ElSelect
v-model="localProps.style"
@change="updateProps('style', localProps.style)"
>
<ElOption
:label="$t('contract-design.solid')"
value="solid"
/>
<ElOption
:label="$t('contract-design.dashed')"
value="dashed"
/>
<ElOption
:label="$t('contract-design.dotted')"
value="dotted"
/>
</ElSelect>
</ElFormItem>
<ElFormItem :label="$t('contract-design.lineColor')">
<ElColorPicker
v-model="localProps.color"
@change="updateProps('color', localProps.color)"
/>
</ElFormItem>
<ElFormItem :label="$t('contract-design.marginVertical')">
<ElInputNumber
v-model="localProps.margin"
:min="0"
:max="100"
@change="updateProps('margin', localProps.margin)"
/>
</ElFormItem>
</template>
<!-- 图片属性 -->
<template v-else-if="activeElement.type === 'image'">
<ElFormItem :label="$t('contract-design.imageUrl')">
<ElInput
v-model="localProps.src"
:placeholder="$t('contract-design.enterImageUrl')"
@change="updateProps('src', localProps.src)"
/>
</ElFormItem>
<ElFormItem :label="$t('contract-design.width')">
<ElInputNumber
v-model="localProps.width"
:min="50"
:max="700"
@change="updateProps('width', localProps.width)"
/>
</ElFormItem>
<ElFormItem :label="$t('contract-design.alignment')">
<ElRadioGroup
v-model="localProps.align"
@change="updateProps('align', localProps.align)"
>
<ElRadioButton value="left">
{{ $t('contract-design.left') }}
</ElRadioButton>
<ElRadioButton value="center">
{{ $t('contract-design.center') }}
</ElRadioButton>
<ElRadioButton value="right">
{{ $t('contract-design.right') }}
</ElRadioButton>
</ElRadioGroup>
</ElFormItem>
</template>
<!-- 富文本属性 -->
<template v-else-if="activeElement.type === 'rich-text'">
<ElFormItem :label="$t('contract-design.minHeight')">
<ElInputNumber
v-model="localProps.minHeight"
:min="50"
:max="800"
@change="updateProps('minHeight', localProps.minHeight)"
/>
</ElFormItem>
<ElFormItem :label="$t('contract-design.padding')">
<ElInputNumber
v-model="localProps.padding"
:min="0"
:max="50"
@change="updateProps('padding', localProps.padding)"
/>
</ElFormItem>
<ElFormItem :label="$t('contract-design.showBorder')">
<ElSwitch
v-model="localProps.showBorder"
@change="updateProps('showBorder', localProps.showBorder)"
/>
</ElFormItem>
<ElFormItem
v-if="localProps.showBorder"
:label="$t('contract-design.borderColor')"
>
<ElColorPicker
v-model="localProps.borderColor"
@change="updateProps('borderColor', localProps.borderColor)"
/>
</ElFormItem>
<ElFormItem :label="$t('contract-design.backgroundColor')">
<ElColorPicker
v-model="localProps.backgroundColor"
@change="
updateProps('backgroundColor', localProps.backgroundColor)
"
/>
</ElFormItem>
<ElDivider />
<div
class="rounded bg-[var(--el-color-primary-light-9)] p-3 text-xs text-[var(--el-color-primary)]"
>
<div class="mb-1 font-bold">
{{ $t('contract-design.editTip') }}
</div>
<div>{{ $t('contract-design.richTextEditDesc') }}</div>
<ul class="mt-1 list-inside list-disc">
<li>{{ $t('contract-design.boldItalicUnderline') }}</li>
<li>{{ $t('contract-design.headingListQuote') }}</li>
<li>{{ $t('contract-design.insertContent') }}</li>
</ul>
</div>
</template>
</ElForm>
</div>
</ElScrollbar>
</ElTabPane>
<!-- 模板设置 -->
<ElTabPane
:label="$t('contract-design.templatePanel')"
name="template"
class="h-full"
>
<ElScrollbar class="h-full">
<div class="p-4">
<ElForm label-position="top" size="small">
<ElFormItem :label="$t('contract-design.templateName')">
<ElInput
:model-value="templateConfig.name"
@change="(val: string) => updateTemplateField('name', val)"
/>
</ElFormItem>
<ElFormItem :label="$t('contract-design.templateCode')">
<ElInput
:model-value="templateConfig.code"
@change="(val: string) => updateTemplateField('code', val)"
/>
</ElFormItem>
<ElFormItem :label="$t('contract-design.templateCategory')">
<ElInput
:model-value="templateConfig.category"
@change="
(val: string) => updateTemplateField('category', val)
"
/>
</ElFormItem>
<ElFormItem :label="$t('contract-design.templateDescription')">
<ElInput
:model-value="templateConfig.description"
type="textarea"
:rows="3"
@change="
(val: string) => updateTemplateField('description', val)
"
/>
</ElFormItem>
<ElDivider>{{ $t('contract-design.pageSettings') }}</ElDivider>
<ElFormItem :label="$t('contract-design.pageSize')">
<ElSelect
:model-value="templateConfig.pageSize"
@change="
(val: 'A4' | 'A5' | 'Letter') =>
updateTemplateField('pageSize', val)
"
>
<ElOption label="A4" value="A4" />
<ElOption label="A5" value="A5" />
<ElOption label="Letter" value="Letter" />
</ElSelect>
</ElFormItem>
<ElFormItem :label="$t('contract-design.pageMargin')">
<div class="grid grid-cols-2 gap-2">
<ElInputNumber
:model-value="templateConfig.pageMargin.top"
:min="0"
:max="200"
:placeholder="$t('contract-design.top')"
@change="(val: number) => updateMargin('top', val)"
/>
<ElInputNumber
:model-value="templateConfig.pageMargin.right"
:min="0"
:max="200"
:placeholder="$t('contract-design.rightLabel')"
@change="(val: number) => updateMargin('right', val)"
/>
<ElInputNumber
:model-value="templateConfig.pageMargin.bottom"
:min="0"
:max="200"
:placeholder="$t('contract-design.bottom')"
@change="(val: number) => updateMargin('bottom', val)"
/>
<ElInputNumber
:model-value="templateConfig.pageMargin.left"
:min="0"
:max="200"
:placeholder="$t('contract-design.leftLabel')"
@change="(val: number) => updateMargin('left', val)"
/>
</div>
</ElFormItem>
</ElForm>
</div>
</ElScrollbar>
</ElTabPane>
<!-- 变量管理 -->
<ElTabPane
:label="$t('contract-design.variablePanel')"
name="variables"
class="h-full"
>
<ElScrollbar class="h-full">
<div class="p-4">
<div
class="mb-4 rounded bg-[var(--el-color-info-light-9)] p-3 text-xs text-[var(--el-text-color-secondary)]"
>
<div class="mb-1 font-bold">
{{ $t('contract-design.variableDescription') }}
</div>
<div>
{{ $t('contract-design.variableDescText') }}
</div>
</div>
<!-- 已使用的变量 -->
<div v-if="templateConfig.variables.length > 0" class="mb-4">
<div
class="mb-2 text-xs font-bold text-[var(--el-text-color-primary)]"
>
{{ $t('contract-design.usedVariables') }} ({{
templateConfig.variables.length
}})
</div>
<div
v-for="variable in templateConfig.variables"
:key="variable.code"
class="mb-2 rounded border border-[var(--el-border-color)] p-2"
>
<div class="flex items-center justify-between">
<div class="text-sm font-medium">{{ variable.name }}</div>
<ElButton
type="danger"
text
size="small"
@click="store.removeVariable(variable.code)"
>
{{ $t('contract-design.remove') }}
</ElButton>
</div>
<div class="mt-1 flex items-center gap-2">
<span class="text-xs text-[var(--el-text-color-placeholder)]">
{{ variable.code }}
</span>
<span
class="rounded px-1.5 py-0.5 text-xs"
:class="{
'bg-[var(--el-color-danger-light-9)] text-[var(--el-color-danger)]':
variable.required,
'bg-[var(--el-fill-color)] text-[var(--el-text-color-secondary)]':
!variable.required,
}"
>
{{ variable.required ? '必填' : '选填' }}
</span>
<span
class="rounded bg-[var(--el-fill-color)] px-1.5 py-0.5 text-xs text-[var(--el-text-color-secondary)]"
>
{{ variableTypeLabels[variable.type] || variable.type }}
</span>
</div>
</div>
</div>
<div
v-else
class="mb-4 py-8 text-center text-sm text-[var(--el-text-color-placeholder)]"
>
{{ $t('contract-design.noVariablesText') }}
</div>
<!-- 可用变量列表 -->
<ElDivider>
{{ $t('contract-design.availableVariables') }}
</ElDivider>
<div class="grid grid-cols-2 gap-2">
<div
v-for="v in predefinedVariables"
:key="v.code"
class="cursor-pointer rounded border border-[var(--el-border-color)] p-2 text-xs transition-colors hover:border-[var(--el-color-primary)] hover:bg-[var(--el-color-primary-light-9)]"
:class="{
'border-[var(--el-color-success)] bg-[var(--el-color-success-light-9)]':
isVariableUsed(v.code),
}"
@click="addVariableToTemplate(v)"
>
<div class="font-medium">{{ v.name }}</div>
<div class="mt-0.5 text-[var(--el-text-color-placeholder)]">
{{ v.code }}
</div>
</div>
</div>
</div>
</ElScrollbar>
</ElTabPane>
</ElTabs>
</div>
</template>
<style scoped>
:deep(.el-tabs__content) {
height: calc(100% - 40px);
overflow: hidden;
}
:deep(.el-tab-pane) {
height: 100%;
}
</style>
@@ -1,569 +0,0 @@
<script setup lang="ts">
import type {
ContractElement,
ContractTemplateConfig,
} from '../store/contractDesignStore';
import { computed, nextTick, ref } from 'vue';
import { Check, Pencil } from '@vben/icons';
import { $t } from '@vben/locales';
import { ElDialog, ElIcon, ElInput } from 'element-plus';
import ElementRenderer from './ElementRenderer.vue';
import SignatureDialog from './SignatureDialog.vue';
const props = withDefaults(
defineProps<{
config: ContractTemplateConfig;
editable?: boolean; // 是否可编辑变量
seals?: Record<string, string>;
showBorder?: boolean;
signable?: boolean; // 是否可签署模式
signatures?: Record<string, string>;
variables?: Record<string, any>;
}>(),
{
variables: () => ({}),
signatures: () => ({}),
seals: () => ({}),
showBorder: false,
signable: false,
editable: false,
},
);
const emit = defineEmits<{
(e: 'sign', elementId: string, signatureData: string): void;
(e: 'update:variables', variables: Record<string, any>): void;
(e: 'variableChange', code: string, value: string): void;
}>();
// 签名弹窗状态
const signDialogVisible = ref(false);
const currentSignElement = ref<ContractElement | null>(null);
// 页面尺寸映射
const pageSizeMap = {
A4: { width: 794, height: 1123 },
A5: { width: 559, height: 794 },
Letter: { width: 816, height: 1056 },
};
// 画布样式
const canvasStyle = computed(() => {
const size = pageSizeMap[props.config.pageSize];
const margin = props.config.pageMargin;
return {
width: `${size.width}px`,
minHeight: `${size.height}px`,
// padding: `${margin.top}px ${margin.right}px ${margin.bottom}px ${margin.left}px`,
};
});
// 合并变量值到元素
const getElementVariables = (element: any) => {
if (element.type === 'variable' && element.props.variableCode) {
return {
...props.variables,
[element.props.variableCode]: props.variables[element.props.variableCode],
};
}
return props.variables;
};
// 获取签名图片
const getSignatureImage = (element: any) => {
if (element.signature?.partyType) {
return props.signatures[element.signature.partyType];
}
return '';
};
// 获取印章图片
const getSealImage = (element: any) => {
if (element.signature?.partyType) {
return props.seals[element.signature.partyType];
}
return '';
};
// 检查元素是否已签署
const isSigned = (element: ContractElement) => {
return !!props.signatures[element.id];
};
// 获取元素签名图片(按元素ID
const getElementSignature = (element: ContractElement) => {
return props.signatures[element.id] || '';
};
// 点击签名区
const handleSignClick = (element: ContractElement) => {
if (!props.signable) return;
currentSignElement.value = element;
signDialogVisible.value = true;
};
// 确认签名
const handleSignConfirm = (signatureData: string) => {
if (currentSignElement.value) {
emit('sign', currentSignElement.value.id, signatureData);
}
currentSignElement.value = null;
};
// 签署区容器对齐样式
const getSignContainerStyle = (element: ContractElement) => {
const align = element.props.align || 'right';
return {
display: 'flex',
justifyContent:
align === 'center'
? 'center'
: align === 'right'
? 'flex-end'
: 'flex-start',
};
};
// ============ 变量编辑功能 ============
// 当前编辑的变量
const editingVariableCode = ref<null | string>(null);
const editingVariableValue = ref('');
const variableInputRef = ref<InstanceType<typeof ElInput> | null>(null);
// 获取变量显示值
const getVariableDisplayValue = (code: string, placeholder?: string) => {
const value = props.variables[code];
if (value !== undefined && value !== '') {
return value;
}
return placeholder || $t('contract-design.clickToFill');
};
// 检查变量是否已填写
const isVariableFilled = (code: string) => {
const value = props.variables[code];
return value !== undefined && value !== '';
};
// 点击变量开始编辑
const handleVariableClick = (code: string) => {
if (!props.editable) return;
editingVariableCode.value = code;
editingVariableValue.value = props.variables[code] || '';
nextTick(() => {
variableInputRef.value?.focus();
});
};
// 保存变量编辑
const handleVariableSave = () => {
if (editingVariableCode.value) {
const newVariables = {
...props.variables,
[editingVariableCode.value]: editingVariableValue.value,
};
emit('update:variables', newVariables);
emit(
'variableChange',
editingVariableCode.value,
editingVariableValue.value,
);
}
editingVariableCode.value = null;
};
// 取消变量编辑
const handleVariableCancel = () => {
editingVariableCode.value = null;
};
</script>
<template>
<div class="contract-renderer">
<div
class="contract-paper bg-[var(--el-bg-color)]"
:class="{ 'shadow-lg': showBorder }"
:style="canvasStyle"
>
<template v-if="config.elements.length > 0">
<div
v-for="element in config.elements"
:key="element.id"
class="contract-element"
>
<!-- 签名区特殊处理 -->
<template v-if="element.type === 'signature-zone'">
<div
class="signature-container"
:style="getSignContainerStyle(element)"
>
<div class="signature-wrapper">
<!-- 标签 -->
<div
v-if="element.props.showLabel !== false"
class="signature-label mb-1 text-sm text-[var(--el-text-color-regular)]"
>
{{
element.signature?.partyLabel ||
element.props.label ||
$t('contract-design.signature')
}}
</div>
<!-- 已签名显示 -->
<div
v-if="getElementSignature(element)"
class="signature-display signed"
>
<img
:src="getElementSignature(element)"
class="signature-image"
:style="{
width: `${element.props.width}px`,
height: `${element.props.height}px`,
}"
alt="签名"
/>
<div class="signed-badge">
<ElIcon :size="12"><Check /></ElIcon>
{{ $t('contract-design.signed') }}
</div>
</div>
<!-- 未签名 - 可签署模式 -->
<div
v-else-if="signable"
class="signature-placeholder signable"
:style="{
width: `${element.props.width}px`,
height: `${element.props.height}px`,
border: element.props.showBorder
? '2px dashed var(--el-color-primary)'
: 'none',
}"
@click="handleSignClick(element)"
>
<ElIcon :size="24" class="mb-1"><Pencil /></ElIcon>
<span class="text-sm">{{
$t('contract-design.clickToSign')
}}</span>
</div>
<!-- 未签名 - 只读模式 -->
<ElementRenderer
v-else
:element="element"
:is-design="false"
:variables="getElementVariables(element)"
/>
<!-- 签署日期 -->
<div
v-if="
element.signature?.showDate && getElementSignature(element)
"
class="signature-date mt-1 text-sm text-[var(--el-text-color-secondary)]"
>
{{ $t('contract-design.dateLabel') }}{{
new Date().toLocaleDateString('zh-CN')
}}
</div>
</div>
</div>
</template>
<!-- 盖章区特殊处理 -->
<template v-else-if="element.type === 'seal-zone'">
<div class="seal-container" :style="getSignContainerStyle(element)">
<div class="seal-wrapper">
<!-- 标签 -->
<div
v-if="element.props.showLabel !== false"
class="seal-label mb-1 text-sm text-[var(--el-text-color-regular)]"
>
{{
element.signature?.partyLabel ||
element.props.label ||
$t('contract-design.sealArea')
}}
</div>
<!-- 已盖章显示 -->
<div
v-if="getSealImage(element) || getElementSignature(element)"
class="seal-display signed"
>
<img
:src="getSealImage(element) || getElementSignature(element)"
class="seal-image"
:style="{
width: `${element.props.width}px`,
height: `${element.props.height}px`,
}"
alt="印章"
/>
<div class="signed-badge">
<ElIcon :size="12"><Check /></ElIcon>
{{ $t('contract-design.sealed') }}
</div>
</div>
<!-- 未盖章 - 可签署模式 -->
<div
v-else-if="signable"
class="seal-placeholder signable"
:style="{
width: `${element.props.width}px`,
height: `${element.props.height}px`,
border: element.props.showBorder
? '2px dashed var(--el-color-primary)'
: 'none',
}"
@click="handleSignClick(element)"
>
<ElIcon :size="32" class="mb-1"><Pencil /></ElIcon>
<span class="text-sm">{{
$t('contract-design.clickToSeal')
}}</span>
</div>
<!-- 未盖章 - 只读模式 -->
<ElementRenderer
v-else
:element="element"
:is-design="false"
:variables="getElementVariables(element)"
/>
</div>
</div>
</template>
<!-- 变量元素 - 可编辑模式 -->
<template v-else-if="element.type === 'variable' && editable">
<span
class="editable-variable"
:class="{
'variable-filled': isVariableFilled(element.props.variableCode),
'variable-empty': !isVariableFilled(element.props.variableCode),
}"
:style="{
borderBottom: element.props.underline
? '1px solid currentColor'
: undefined,
minWidth: element.props.minWidth
? `${element.props.minWidth}px`
: '60px',
display: 'inline-block',
}"
@click="handleVariableClick(element.props.variableCode)"
>
{{
getVariableDisplayValue(
element.props.variableCode,
element.props.placeholder,
)
}}
</span>
</template>
<!-- 其他元素 -->
<template v-else>
<ElementRenderer
:element="element"
:is-design="false"
:variables="getElementVariables(element)"
:editable="editable"
@variable-click="handleVariableClick"
/>
</template>
</div>
</template>
<div
v-else
class="flex h-40 items-center justify-center text-[var(--el-text-color-placeholder)]"
>
{{ $t('contract-design.noContent') }}
</div>
</div>
<!-- 签名弹窗 -->
<SignatureDialog
v-model:visible="signDialogVisible"
:party-label="
currentSignElement?.signature?.partyLabel ||
currentSignElement?.props?.label
"
:width="currentSignElement?.props?.width || 400"
:height="currentSignElement?.props?.height || 150"
@confirm="handleSignConfirm"
/>
<!-- 变量编辑弹窗 -->
<ElDialog
:model-value="!!editingVariableCode"
:title="$t('contract-design.fillVariable')"
width="400px"
:close-on-click-modal="false"
@close="handleVariableCancel"
>
<div class="variable-edit-dialog">
<div class="mb-2 text-sm text-[var(--el-text-color-secondary)]">
{{ $t('contract-design.enterVariableValue') }}
</div>
<ElInput
ref="variableInputRef"
v-model="editingVariableValue"
:placeholder="$t('contract-design.enterVariableValue')"
size="large"
clearable
@keyup.enter="handleVariableSave"
/>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<button class="var-btn" @click="handleVariableCancel">
{{ $t('contract-design.cancel') }}
</button>
<button class="var-btn var-btn-primary" @click="handleVariableSave">
{{ $t('contract-design.confirm') }}
</button>
</div>
</template>
</ElDialog>
</div>
</template>
<style scoped>
.contract-renderer {
display: flex;
justify-content: center;
}
.contract-paper {
box-sizing: border-box;
}
.contract-element {
margin-bottom: 4px;
}
.signature-display,
.seal-display {
position: relative;
display: inline-block;
}
.signature-display.signed,
.seal-display.signed {
padding: 4px;
background: var(--el-color-success-light-9);
border: 1px solid var(--el-color-success-light-5);
border-radius: 4px;
}
.signature-image,
.seal-image {
display: block;
object-fit: contain;
}
.signature-placeholder,
.seal-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--el-text-color-placeholder);
background: var(--el-fill-color-light);
border-radius: 4px;
transition: all 0.2s;
}
.signature-placeholder.signable,
.seal-placeholder.signable {
color: var(--el-color-primary);
cursor: pointer;
background: var(--el-color-primary-light-9);
}
.signature-placeholder.signable:hover,
.seal-placeholder.signable:hover {
background: var(--el-color-primary-light-8);
border-color: var(--el-color-primary) !important;
}
.signed-badge {
position: absolute;
top: -8px;
right: -8px;
display: flex;
gap: 2px;
align-items: center;
padding: 2px 8px;
font-size: 11px;
color: var(--el-color-success);
background: var(--el-bg-color);
border: 1px solid var(--el-color-success);
border-radius: 10px;
}
/* 可编辑变量样式 */
.editable-variable {
padding: 2px 6px;
cursor: pointer;
border-radius: 3px;
transition: all 0.2s;
}
.editable-variable:hover {
background: var(--el-color-primary-light-9);
}
.editable-variable.variable-filled {
color: var(--el-text-color-primary);
}
.editable-variable.variable-empty {
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
border: 1px dashed var(--el-color-primary-light-5);
}
.editable-variable.variable-empty:hover {
background: var(--el-color-primary-light-8);
border-color: var(--el-color-primary);
}
/* 变量编辑弹窗按钮 */
.var-btn {
padding: 4px 12px;
font-size: 12px;
color: var(--el-text-color-regular);
cursor: pointer;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color);
border-radius: 4px;
transition: all 0.2s;
}
.var-btn:hover {
color: var(--el-color-primary);
border-color: var(--el-color-primary);
}
.var-btn-primary {
color: white;
background: var(--el-color-primary);
border-color: var(--el-color-primary);
}
.var-btn-primary:hover {
background: var(--el-color-primary-light-3);
border-color: var(--el-color-primary-light-3);
}
</style>
@@ -1,356 +0,0 @@
<script setup lang="ts">
import type {
ContractTemplateConfig,
PartyType,
VariableConfig,
} from '../store/contractDesignStore';
import { computed, ref, watch } from 'vue';
import { Check, FileSignature, Pencil } from '@vben/icons';
import { $t } from '@vben/locales';
import {
ElButton,
ElDatePicker,
ElDialog,
ElDivider,
ElForm,
ElFormItem,
ElIcon,
ElInput,
ElInputNumber,
ElMessage,
ElScrollbar,
ElStep,
ElSteps,
ElTabPane,
ElTabs,
} from 'element-plus';
import ContractRenderer from './ContractRenderer.vue';
import SignaturePad from './SignaturePad.vue';
const props = defineProps<{
config: ContractTemplateConfig;
partyType?: PartyType;
visible: boolean;
}>();
const emit = defineEmits<{
(e: 'update:visible', value: boolean): void;
(
e: 'signed',
data: { seal?: string; signature: string; variables: Record<string, any> },
): void;
}>();
const dialogVisible = computed({
get: () => props.visible,
set: (val) => emit('update:visible', val),
});
// 当前步骤
const currentStep = ref(0);
// 变量值
const variableValues = ref<Record<string, any>>({});
// 签名数据
const signatureData = ref('');
const sealData = ref('');
// 签名板引用
const signaturePadRef = ref<InstanceType<typeof SignaturePad> | null>(null);
// 当前签署方标签
const currentPartyLabel = computed(() => {
const party = props.config.parties.find((p) => p.type === props.partyType);
return party?.label || $t('contract-design.signer');
});
// 需要填写的变量(根据签署方过滤)
const requiredVariables = computed(() => {
const partyPrefix = props.partyType?.replace('party_', '') || '';
return props.config.variables.filter((v) => {
// 通用变量
if (!v.code.startsWith('party_')) return true;
// 当前签署方的变量
if (v.code.startsWith(`party_${partyPrefix}_`)) return true;
return false;
});
});
// 初始化变量值
watch(
() => props.visible,
(visible) => {
if (visible) {
currentStep.value = 0;
signatureData.value = '';
sealData.value = '';
// 初始化变量默认值
const values: Record<string, any> = {};
requiredVariables.value.forEach((v) => {
values[v.code] = v.defaultValue || '';
});
variableValues.value = values;
}
},
);
// 获取变量输入类型
const getVariableInputType = (variable: VariableConfig) => {
switch (variable.type) {
case 'date': {
return 'date';
}
case 'money':
case 'number': {
return 'number';
}
default: {
return 'text';
}
}
};
// 下一步
const handleNext = () => {
if (currentStep.value === 0) {
// 验证必填变量
const missingRequired = requiredVariables.value.filter(
(v) => v.required && !variableValues.value[v.code],
);
if (missingRequired.length > 0) {
ElMessage.warning(
$t('contract-design.pleaseCompleteMandatoryFields', {
fields: missingRequired.map((v) => v.name).join('、'),
}),
);
return;
}
}
currentStep.value++;
};
// 上一步
const handlePrev = () => {
currentStep.value--;
};
// 确认签署
const handleSign = () => {
if (!signatureData.value) {
ElMessage.warning($t('contract-design.pleaseSignFirst'));
return;
}
emit('signed', {
variables: variableValues.value,
signature: signatureData.value,
seal: sealData.value,
});
dialogVisible.value = false;
ElMessage.success($t('contract-design.signedSuccessfully'));
};
// 签名变化
const handleSignatureChange = (data: string) => {
signatureData.value = data;
};
</script>
<template>
<ElDialog
v-model="dialogVisible"
:title="`${$t('contract-design.contractSigning')} - ${currentPartyLabel}`"
width="900px"
:close-on-click-modal="false"
destroy-on-close
>
<template #header>
<div class="flex items-center gap-2">
<ElIcon :size="20" class="text-[var(--el-color-primary)]">
<FileSignature />
</ElIcon>
<span
>{{ $t('contract-design.contractSigning') }} -
{{ currentPartyLabel }}</span
>
</div>
</template>
<!-- 步骤条 -->
<ElSteps :active="currentStep" finish-status="success" class="mb-6">
<ElStep :title="$t('contract-design.fillInformation')" />
<ElStep :title="$t('contract-design.previewContract')" />
<ElStep :title="$t('contract-design.signatureConfirmation')" />
</ElSteps>
<!-- 步骤内容 -->
<div class="step-content">
<!-- 步骤1填写变量 -->
<div v-show="currentStep === 0" class="step-panel">
<ElScrollbar max-height="400px">
<ElForm label-position="top" label-width="auto">
<div class="grid grid-cols-2 gap-4">
<ElFormItem
v-for="variable in requiredVariables"
:key="variable.code"
:label="variable.name"
:required="variable.required"
>
<!-- 文本输入 -->
<ElInput
v-if="getVariableInputType(variable) === 'text'"
v-model="variableValues[variable.code]"
:placeholder="
$t('contract-design.pleaseEnter', { field: variable.name })
"
/>
<!-- 数字输入 -->
<ElInputNumber
v-else-if="getVariableInputType(variable) === 'number'"
v-model="variableValues[variable.code]"
:placeholder="
$t('contract-design.pleaseEnter', { field: variable.name })
"
:precision="variable.type === 'money' ? 2 : 0"
:controls="false"
class="w-full"
/>
<!-- 日期选择 -->
<ElDatePicker
v-else-if="getVariableInputType(variable) === 'date'"
v-model="variableValues[variable.code]"
type="date"
:placeholder="
$t('contract-design.pleaseSelect', { field: variable.name })
"
:format="variable.format || 'YYYY-MM-DD'"
:value-format="variable.format || 'YYYY-MM-DD'"
class="w-full"
/>
</ElFormItem>
</div>
</ElForm>
</ElScrollbar>
</div>
<!-- 步骤2预览合同 -->
<div v-show="currentStep === 1" class="step-panel">
<ElScrollbar max-height="500px">
<div class="flex justify-center bg-[var(--el-fill-color-light)] p-4">
<ContractRenderer
:config="config"
:variables="variableValues"
show-border
/>
</div>
</ElScrollbar>
</div>
<!-- 步骤3签名确认 -->
<div v-show="currentStep === 2" class="step-panel">
<ElTabs type="border-card">
<ElTabPane>
<template #label>
<span class="flex items-center gap-1">
<ElIcon><Pencil /></ElIcon>
{{ $t('contract-design.drawSignature') }}
</span>
</template>
<div class="flex flex-col items-center py-4">
<div class="mb-2 text-sm text-[var(--el-text-color-secondary)]">
{{ $t('contract-design.pleaseSignInArea') }}
</div>
<SignaturePad
ref="signaturePadRef"
:width="400"
:height="150"
:model-value="signatureData"
@change="handleSignatureChange"
/>
</div>
</ElTabPane>
</ElTabs>
<ElDivider />
<!-- 签名预览 -->
<div v-if="signatureData" class="signature-preview">
<div class="mb-2 text-sm font-bold">
{{ $t('contract-design.signaturePreview') }}
</div>
<div class="flex items-center gap-4">
<div class="rounded border border-[var(--el-border-color)] p-2">
<img
:src="signatureData"
:alt="$t('contract-design.signaturePreview')"
class="h-16"
/>
</div>
<div class="text-sm text-[var(--el-text-color-secondary)]">
<div>
{{ $t('contract-design.signer') }}{{ currentPartyLabel }}
</div>
<div>
{{ $t('contract-design.signingTime') }}{{
new Date().toLocaleString('zh-CN')
}}
</div>
</div>
</div>
</div>
</div>
</div>
<template #footer>
<div class="flex justify-between">
<div>
<ElButton v-if="currentStep > 0" @click="handlePrev">
{{ $t('contract-design.previousStep') }}
</ElButton>
</div>
<div>
<ElButton @click="dialogVisible = false">
{{ $t('contract-design.cancel') }}
</ElButton>
<ElButton v-if="currentStep < 2" type="primary" @click="handleNext">
{{ $t('contract-design.nextStep') }}
</ElButton>
<ElButton
v-else
type="primary"
:disabled="!signatureData"
@click="handleSign"
>
<ElIcon class="mr-1"><Check /></ElIcon>
{{ $t('contract-design.confirmSigning') }}
</ElButton>
</div>
</div>
</template>
</ElDialog>
</template>
<style scoped>
.step-content {
min-height: 300px;
}
.step-panel {
padding: 16px 0;
}
:deep(.el-input-number) {
width: 100%;
}
:deep(.el-input-number .el-input__wrapper) {
padding-right: 11px;
padding-left: 11px;
}
</style>
@@ -1,376 +0,0 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import {
Code,
Download,
Eye,
FileText,
RotateCcw,
RotateCw,
Save,
Trash2,
Upload,
} from '@vben/icons';
import { $t } from '@vben/locales';
import {
ElButton,
ElEmpty,
ElIcon,
ElScrollbar,
ElTooltip,
} from 'element-plus';
import { storeToRefs } from 'pinia';
import draggable from 'vuedraggable';
import { useContractDesignStore } from '../store/contractDesignStore';
import ElementWrapper from './ElementWrapper.vue';
const emit = defineEmits<{
(e: 'preview'): void;
(e: 'view-code'): void;
(e: 'clear'): void;
(e: 'import'): void;
(e: 'export'): void;
(e: 'save'): void;
}>();
const store = useContractDesignStore();
const { templateConfig, canUndo, canRedo, isDragging } = storeToRefs(store);
// 页面尺寸映射(单位:px,96dpi)
const pageSizeMap = {
A4: { width: 794, height: 1123 },
A5: { width: 559, height: 794 },
Letter: { width: 816, height: 1056 },
};
// 画布引用
const canvasPaperRef = ref<HTMLElement | null>(null);
// 分页线位置列表
const pageBreakLines = ref<number[]>([]);
// 计算内容区域高度(减去上下边距)
const contentAreaHeight = computed(() => {
const size = pageSizeMap[templateConfig.value.pageSize];
const margin = templateConfig.value.pageMargin;
return size.height - margin.top - margin.bottom;
});
// 画布样式
const canvasStyle = computed(() => {
const size = pageSizeMap[templateConfig.value.pageSize];
const margin = templateConfig.value.pageMargin;
return {
width: `${size.width}px`,
minHeight: `${size.height}px`,
padding: `${margin.top}px ${margin.right}px ${margin.bottom}px ${margin.left}px`,
};
});
// 计算分页线位置
const calculatePageBreaks = () => {
if (!canvasPaperRef.value) return;
const pageHeight = pageSizeMap[templateConfig.value.pageSize].height;
const margin = templateConfig.value.pageMargin;
// 获取内容区域的实际高度(不包含 padding)
const contentEl = canvasPaperRef.value.querySelector(
'.sortable-content',
) as HTMLElement;
const actualContentHeight = contentEl ? contentEl.scrollHeight : 0;
// 每页内容区域高度(不含边距)
const pageContentHeight = pageHeight - margin.top - margin.bottom;
// 计算需要多少页
const pageCount = Math.ceil(actualContentHeight / pageContentHeight);
// 生成分页线位置
// 分页线应该在每页内容区域结束的位置(相对于画布顶部)
const lines: number[] = [];
for (let i = 1; i < pageCount; i++) {
// 分页线位置 = 顶部边距 + 每页内容高度 * 页数
lines.push(margin.top + pageContentHeight * i);
}
pageBreakLines.value = lines;
};
// 监听元素变化,重新计算分页
watch(
() => templateConfig.value.elements,
() => {
nextTick(() => {
calculatePageBreaks();
});
},
{ deep: true },
);
// 使用 ResizeObserver 监听画布大小变化
let resizeObserver: null | ResizeObserver = null;
onMounted(() => {
nextTick(() => {
calculatePageBreaks();
if (canvasPaperRef.value) {
resizeObserver = new ResizeObserver(() => {
calculatePageBreaks();
});
resizeObserver.observe(canvasPaperRef.value);
}
});
});
onUnmounted(() => {
if (resizeObserver) {
resizeObserver.disconnect();
}
});
// 点击画布空白处取消选中
const handleCanvasClick = () => {
store.setActive(null);
};
// 拖拽结束后记录快照
const handleDragEnd = () => {
store.setDragging(false);
store.recordSnapshot();
};
// 处理拖拽添加
const handleDragAdd = () => {
store.recordSnapshot();
};
</script>
<template>
<div
class="design-canvas flex h-full flex-col rounded border border-[var(--el-border-color)] bg-[var(--el-bg-color)]"
>
<!-- 工具栏 -->
<div
class="canvas-toolbar flex flex-shrink-0 items-center justify-between border-b border-[var(--el-border-color)] px-4 py-2"
>
<div class="flex items-center gap-2">
<ElIcon :size="18" class="text-[var(--el-color-primary)]">
<FileText />
</ElIcon>
<span class="text-sm font-bold">{{
templateConfig.name || $t('contract-design.contractTemplate')
}}</span>
<span class="text-xs text-[var(--el-text-color-placeholder)]">
({{ templateConfig.elements.length }}
{{ $t('contract-design.elements') }})
</span>
</div>
<div class="flex items-center gap-1">
<!-- 撤销/重做 -->
<ElTooltip
:content="`${$t('contract-design.undo')} (Ctrl+Z)`"
placement="bottom"
>
<ElButton text :disabled="!canUndo" @click="store.undo()">
<ElIcon :size="16"><RotateCcw /></ElIcon>
</ElButton>
</ElTooltip>
<ElTooltip
:content="`${$t('contract-design.redo')} (Ctrl+Y)`"
placement="bottom"
>
<ElButton text :disabled="!canRedo" @click="store.redo()">
<ElIcon :size="16"><RotateCw /></ElIcon>
</ElButton>
</ElTooltip>
<div class="mx-2 h-4 w-px bg-[var(--el-border-color)]"></div>
<!-- 预览 -->
<ElTooltip :content="$t('contract-design.preview')" placement="bottom">
<ElButton text @click="emit('preview')">
<ElIcon :size="16"><Eye /></ElIcon>
</ElButton>
</ElTooltip>
<!-- 查看代码 -->
<ElTooltip :content="$t('contract-design.viewJSON')" placement="bottom">
<ElButton text @click="emit('view-code')">
<ElIcon :size="16"><Code /></ElIcon>
</ElButton>
</ElTooltip>
<div class="mx-2 h-4 w-px bg-[var(--el-border-color)]"></div>
<!-- 导入 -->
<ElTooltip :content="$t('contract-design.import')" placement="bottom">
<ElButton text @click="emit('import')">
<ElIcon :size="16"><Upload /></ElIcon>
</ElButton>
</ElTooltip>
<!-- 导出 -->
<ElTooltip :content="$t('contract-design.export')" placement="bottom">
<ElButton text @click="emit('export')">
<ElIcon :size="16"><Download /></ElIcon>
</ElButton>
</ElTooltip>
<!-- 清空 -->
<ElTooltip :content="$t('contract-design.clear')" placement="bottom">
<ElButton text @click="emit('clear')">
<ElIcon :size="16"><Trash2 /></ElIcon>
</ElButton>
</ElTooltip>
<div class="mx-2 h-4 w-px bg-[var(--el-border-color)]"></div>
<!-- 保存 -->
<ElButton type="primary" size="small" @click="emit('save')">
<ElIcon :size="14" class="mr-1"><Save /></ElIcon>
{{ $t('contract-design.save') }}
</ElButton>
</div>
</div>
<!-- 画布区域 -->
<ElScrollbar class="flex-1">
<div
class="canvas-container flex justify-center bg-[var(--el-fill-color-light)] p-6"
>
<div class="canvas-paper-wrapper relative">
<div
ref="canvasPaperRef"
class="canvas-paper relative bg-[var(--el-bg-color)] shadow-lg"
:style="canvasStyle"
:class="{ 'is-dragging': isDragging }"
@click.self="handleCanvasClick"
>
<!-- 空状态 -->
<ElEmpty
v-if="templateConfig.elements.length === 0 && !isDragging"
:description="$t('contract-design.dragOrClickToAdd')"
:image-size="80"
/>
<!-- 元素列表 -->
<draggable
v-model="templateConfig.elements"
group="contract-design"
item-key="id"
handle=".drag-handle"
ghost-class="ghost-element"
chosen-class="chosen-element"
class="sortable-content"
:animation="200"
@start="store.setDragging(true)"
@end="handleDragEnd"
@add="handleDragAdd"
>
<template #item="{ element, index }">
<ElementWrapper
:element="element"
:index="index"
:total="templateConfig.elements.length"
/>
</template>
</draggable>
<!-- 拖拽提示 -->
<div
v-if="isDragging && templateConfig.elements.length === 0"
class="drop-hint flex h-20 items-center justify-center rounded border-2 border-dashed border-[var(--el-color-primary)] bg-[var(--el-color-primary-light-9)]"
>
<span class="text-sm text-[var(--el-color-primary)]">{{
$t('contract-design.releaseToAdd')
}}</span>
</div>
<!-- 分页线 -->
<div
v-for="(linePos, index) in pageBreakLines"
:key="`page-break-${index}`"
class="page-break-line"
:style="{ top: `${linePos}px` }"
>
<div class="page-break-label">
{{
$t('contract-design.pageBreakLabel', {
page1: index + 1,
page2: index + 2,
})
}}
</div>
</div>
</div>
</div>
</div>
</ElScrollbar>
<!-- 底部状态栏 -->
<div
class="canvas-footer flex flex-shrink-0 items-center justify-between border-t border-[var(--el-border-color)] px-4 py-1.5 text-xs text-[var(--el-text-color-secondary)]"
>
<div>
{{ $t('contract-design.pageSize') }}: {{ templateConfig.pageSize }}
</div>
<div>
{{ $t('contract-design.margin') }}:
{{ templateConfig.pageMargin.top }}px /
{{ templateConfig.pageMargin.right }}px /
{{ templateConfig.pageMargin.bottom }}px /
{{ templateConfig.pageMargin.left }}px
</div>
</div>
</div>
</template>
<style scoped>
.canvas-paper {
transition: box-shadow 0.2s;
}
.canvas-paper.is-dragging {
box-shadow: 0 0 0 2px var(--el-color-primary);
}
.ghost-element {
background: var(--el-color-primary-light-9);
border: 1px dashed var(--el-color-primary);
opacity: 0.5;
}
.chosen-element {
opacity: 0.8;
}
/* 分页线样式 */
.page-break-line {
position: absolute;
right: -20px;
left: -20px;
z-index: 100;
height: 0;
pointer-events: none;
border-top: 2px dashed var(--el-color-danger);
}
.page-break-label {
position: absolute;
top: -10px;
left: 50%;
padding: 2px 12px;
font-size: 11px;
color: var(--el-color-danger);
white-space: nowrap;
background-color: var(--el-color-danger-light-9);
border-radius: 10px;
transform: translateX(-50%);
}
</style>
@@ -1,714 +0,0 @@
<script setup lang="ts">
import type { ContractElement } from '../store/contractDesignStore';
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { Calendar, Circle, Pencil } from '@vben/icons';
import { ElDivider, ElIcon, ElTable, ElTableColumn } from 'element-plus';
const props = defineProps<{
editable?: boolean;
element: ContractElement;
isDesign?: boolean;
variables?: Record<string, any>;
}>();
const emit = defineEmits<{
(e: 'variableClick', code: string): void;
}>();
// 富文本容器引用
const richTextRef = ref<HTMLElement | null>(null);
// 获取变量值
const getVariableValue = (code: string) => {
if (props.variables && props.variables[code]) {
return props.variables[code];
}
return props.element.props.placeholder || `{{${code}}}`;
};
// 检查变量是否已填写
const isVariableFilled = (code: string) => {
return (
props.variables &&
props.variables[code] !== undefined &&
props.variables[code] !== ''
);
};
// 处理富文本中的变量替换
const processRichTextContent = computed(() => {
let content = props.element.props.content || '';
// 替换 【变量名称|变量code】 格式的变量(新格式)
content = content.replaceAll(
/【([^|【】]+)\|([^【】]+)】/g,
(_match: string, name: string, code: string) => {
const trimmedCode = code.trim();
const value = props.variables?.[trimmedCode];
const displayValue =
value !== undefined && value !== '' ? value : name.trim();
const filledClass =
value !== undefined && value !== '' ? 'filled' : 'empty';
if (props.editable) {
return `<span class="rich-text-variable ${filledClass}" data-var-code="${trimmedCode}">${displayValue}</span>`;
} else if (value !== undefined && value !== '') {
return `<span class="variable-replaced">${value}</span>`;
}
// 设计模式下显示带下划线的变量名称
return `<span class="contract-variable-tag" data-variable="${trimmedCode}">${name.trim()}</span>`;
},
);
// 替换 {{variableCode}} 格式的变量(旧格式,兼容)
content = content.replaceAll(
/\{\{([^}]+)\}\}/g,
(match: string, code: string) => {
const trimmedCode = code.trim();
const value = props.variables?.[trimmedCode];
const displayValue =
value !== undefined && value !== '' ? value : `点击填写`;
const filledClass =
value !== undefined && value !== '' ? 'filled' : 'empty';
if (props.editable) {
return `<span class="rich-text-variable ${filledClass}" data-var-code="${trimmedCode}">${displayValue}</span>`;
} else if (value !== undefined && value !== '') {
return `<span class="variable-replaced">${value}</span>`;
}
return match;
},
);
// 替换 contract-variable-tag 标签中的变量(HTML格式,兼容)
content = content.replaceAll(
/<span[^>]*class="contract-variable-tag"[^>]*data-variable="([^"]+)"[^>]*>[^<]*<\/span>/g,
(match: string, code: string) => {
const trimmedCode = code.trim();
const value = props.variables?.[trimmedCode];
const displayValue =
value !== undefined && value !== '' ? value : `点击填写`;
const filledClass =
value !== undefined && value !== '' ? 'filled' : 'empty';
if (props.editable) {
return `<span class="rich-text-variable ${filledClass}" data-var-code="${trimmedCode}">${displayValue}</span>`;
} else if (value !== undefined && value !== '') {
return `<span class="variable-replaced">${value}</span>`;
}
return match;
},
);
return content;
});
// 处理富文本中变量的点击事件
const handleRichTextClick = (e: MouseEvent) => {
if (!props.editable) return;
const target = e.target as HTMLElement;
if (target.classList.contains('rich-text-variable')) {
const varCode = target.dataset.varCode;
if (varCode) {
emit('variableClick', varCode);
}
}
};
// 挂载和卸载事件监听
onMounted(() => {
if (richTextRef.value) {
richTextRef.value.addEventListener('click', handleRichTextClick);
}
});
onUnmounted(() => {
if (richTextRef.value) {
richTextRef.value.removeEventListener('click', handleRichTextClick);
}
});
// 计算样式
const titleStyle = computed(() => {
const { fontSize, fontWeight, align } = props.element.props;
return {
fontSize: `${fontSize}px`,
fontWeight,
textAlign: align,
};
});
const paragraphStyle = computed(() => {
const { fontSize, lineHeight, align, indent } = props.element.props;
return {
fontSize: `${fontSize}px`,
lineHeight,
textAlign: align,
textIndent: indent ? `${indent}em` : undefined,
};
});
const variableStyle = computed(() => {
const { underline, minWidth } = props.element.props;
return {
minWidth: `${minWidth}px`,
borderBottom: underline ? '1px solid var(--el-text-color-primary)' : 'none',
display: 'inline-block',
textAlign: 'center' as const,
};
});
const signatureStyle = computed(() => {
const { width, height, showBorder } = props.element.props;
return {
width: `${width}px`,
height: `${height}px`,
border: showBorder ? '1px dashed var(--el-border-color)' : 'none',
};
});
// 签署区容器对齐样式
const signatureContainerStyle = computed(() => {
const align = props.element.props.align || 'right';
return {
display: 'flex',
justifyContent:
align === 'center'
? 'center'
: align === 'right'
? 'flex-end'
: 'flex-start',
};
});
const imageStyle = computed(() => {
const { width, height, align } = props.element.props;
return {
width: typeof width === 'number' ? `${width}px` : width,
height: height === 'auto' ? 'auto' : `${height}px`,
display: 'block',
margin:
align === 'center' ? '0 auto' : (align === 'right' ? '0 0 0 auto' : '0'),
};
});
</script>
<template>
<div class="element-renderer">
<!-- 标题 -->
<template v-if="element.type === 'title'">
<h1
v-if="element.props.level === 1"
:style="titleStyle"
class="contract-title"
>
{{ element.props.content }}
</h1>
<h2
v-else-if="element.props.level === 2"
:style="titleStyle"
class="contract-title"
>
{{ element.props.content }}
</h2>
<h3 v-else :style="titleStyle" class="contract-title">
{{ element.props.content }}
</h3>
</template>
<!-- 段落 -->
<template v-else-if="element.type === 'paragraph'">
<p :style="paragraphStyle" class="contract-paragraph">
{{ element.props.content }}
</p>
</template>
<!-- 富文本 -->
<template v-else-if="element.type === 'rich-text'">
<div
ref="richTextRef"
class="contract-rich-text"
:class="{ 'rich-text-editable': editable }"
:style="{
minHeight: `${element.props.minHeight}px`,
padding: element.props.padding
? `${element.props.padding}px`
: undefined,
border: element.props.showBorder
? `1px solid ${element.props.borderColor || 'var(--el-border-color)'}`
: undefined,
borderRadius: element.props.showBorder ? '4px' : undefined,
backgroundColor: element.props.backgroundColor || undefined,
}"
v-html="processRichTextContent"
></div>
</template>
<!-- 变量占位符 -->
<template v-else-if="element.type === 'variable'">
<span
:style="variableStyle"
class="contract-variable"
:class="{
'variable-filled': isVariableFilled(element.props.variableCode),
'variable-empty': !isVariableFilled(element.props.variableCode),
}"
>
{{ getVariableValue(element.props.variableCode) }}
</span>
</template>
<!-- 表格 -->
<template v-else-if="element.type === 'table'">
<ElTable
:data="element.tableData || []"
:border="element.props.bordered"
size="small"
class="contract-table"
:header-cell-style="{ backgroundColor: element.props.headerBgColor }"
>
<ElTableColumn
v-for="col in element.tableColumns"
:key="col.key"
:prop="col.key"
:label="col.title"
:width="col.width"
:align="col.align"
/>
</ElTable>
</template>
<!-- 签名区 -->
<template v-else-if="element.type === 'signature-zone'">
<div class="signature-zone-container" :style="signatureContainerStyle">
<div class="signature-zone-wrapper">
<!-- 标签始终显示在签名区上方 -->
<div
v-if="element.props.showLabel !== false"
class="signature-label mb-1 text-sm text-[var(--el-text-color-regular)]"
>
{{
element.signature?.partyLabel || element.props.label || '签名'
}}
</div>
<div class="signature-zone" :style="signatureStyle">
<div class="signature-content">
<ElIcon
:size="24"
class="mb-2 text-[var(--el-text-color-placeholder)]"
>
<Pencil />
</ElIcon>
<div class="text-xs text-[var(--el-text-color-placeholder)]">
点击签名
</div>
</div>
</div>
<div
v-if="element.signature?.showDate"
class="signature-date mt-2 text-xs text-[var(--el-text-color-secondary)]"
>
日期____________
</div>
</div>
</div>
</template>
<!-- 盖章区 -->
<template v-else-if="element.type === 'seal-zone'">
<div class="seal-zone-container" :style="signatureContainerStyle">
<div class="seal-zone-wrapper">
<!-- 标签始终显示在盖章区上方 -->
<div
v-if="element.props.showLabel !== false"
class="seal-label mb-1 text-sm text-[var(--el-text-color-regular)]"
>
{{
element.signature?.partyLabel || element.props.label || '盖章处'
}}
</div>
<div class="seal-zone" :style="signatureStyle">
<div class="seal-content">
<ElIcon
:size="32"
class="mb-2 text-[var(--el-text-color-placeholder)]"
>
<Circle />
</ElIcon>
<div class="text-xs text-[var(--el-text-color-placeholder)]">
点击盖章
</div>
</div>
</div>
</div>
</div>
</template>
<!-- 日期区 -->
<template v-else-if="element.type === 'date-zone'">
<div class="date-zone-container" :style="signatureContainerStyle">
<div class="date-zone inline-flex items-center">
<ElIcon :size="16" class="mr-1 text-[var(--el-text-color-secondary)]">
<Calendar />
</ElIcon>
<span class="text-sm">{{ element.props.label }}</span>
<span
:class="{
'border-b border-[var(--el-text-color-primary)]':
element.props.showUnderline,
}"
class="inline-block min-w-[120px] text-center"
>
{{ element.props.format }}
</span>
</div>
</div>
</template>
<!-- 分割线 -->
<template v-else-if="element.type === 'divider'">
<ElDivider
:border-style="element.props.style"
:style="{
margin: `${element.props.margin}px 0`,
borderColor: element.props.color,
}"
/>
</template>
<!-- 分页符 -->
<template v-else-if="element.type === 'page-break'">
<div class="page-break">
<div class="page-break-line"></div>
<span class="page-break-text">分页符</span>
<div class="page-break-line"></div>
</div>
</template>
<!-- 图片 -->
<template v-else-if="element.type === 'image'">
<img
v-if="element.props.src"
:src="element.props.src"
:style="imageStyle"
class="contract-image"
alt="合同图片"
/>
<div
v-else
:style="{ ...imageStyle, height: '100px' }"
class="image-placeholder flex items-center justify-center rounded border border-dashed border-[var(--el-border-color)] bg-[var(--el-fill-color-light)]"
>
<span class="text-xs text-[var(--el-text-color-placeholder)]"
>图片占位</span
>
</div>
</template>
</div>
</template>
<style scoped>
.contract-title {
padding: 8px 0;
margin: 0;
}
.contract-paragraph {
padding: 4px 0;
margin: 0;
}
.contract-rich-text {
padding: 4px 0;
}
/* 富文本内容样式 */
.contract-rich-text :deep(h1) {
margin: 0.67em 0;
font-size: 2em;
font-weight: bold;
}
.contract-rich-text :deep(h2) {
margin: 0.83em 0;
font-size: 1.5em;
font-weight: bold;
}
.contract-rich-text :deep(h3) {
margin: 1em 0;
font-size: 1.17em;
font-weight: bold;
}
.contract-rich-text :deep(p) {
margin: 1em 0;
}
/* 空段落保持高度,确保空行正常显示 */
.contract-rich-text :deep(p:empty),
.contract-rich-text :deep(p:has(br:only-child)) {
min-height: 1em;
}
.contract-rich-text :deep(p:empty::before) {
content: '\00a0'; /* 不间断空格 */
}
.contract-rich-text :deep(strong),
.contract-rich-text :deep(b) {
font-weight: bold;
}
.contract-rich-text :deep(em),
.contract-rich-text :deep(i) {
font-style: italic;
}
.contract-rich-text :deep(u) {
text-decoration: underline;
}
.contract-rich-text :deep(s),
.contract-rich-text :deep(strike) {
text-decoration: line-through;
}
.contract-rich-text :deep(ul) {
padding-left: 2em;
margin: 1em 0;
list-style-type: disc;
}
.contract-rich-text :deep(ol) {
padding-left: 2em;
margin: 1em 0;
list-style-type: decimal;
}
.contract-rich-text :deep(li) {
margin: 0.5em 0;
}
.contract-rich-text :deep(blockquote) {
padding-left: 1em;
margin: 1em 0;
color: var(--el-text-color-secondary);
border-left: 3px solid var(--el-border-color);
}
.contract-rich-text :deep(a) {
color: var(--el-color-primary);
text-decoration: underline;
}
.contract-rich-text :deep(code) {
padding: 0.2em 0.4em;
font-family: monospace;
background-color: var(--el-fill-color-light);
border-radius: 3px;
}
.contract-rich-text :deep(pre) {
padding: 1em;
overflow-x: auto;
background-color: var(--el-fill-color-light);
border-radius: 4px;
}
.contract-rich-text :deep(table) {
width: 100%;
margin: 1em 0;
border-collapse: collapse;
}
.contract-rich-text :deep(th),
.contract-rich-text :deep(td) {
padding: 8px 12px;
text-align: left;
border: 1px solid var(--el-border-color);
}
.contract-rich-text :deep(th) {
font-weight: bold;
background-color: var(--el-fill-color-light);
}
.contract-rich-text :deep(img) {
max-width: 100%;
height: auto;
}
/* 图片对齐 - 通过 data-alignment 属性 */
.contract-rich-text :deep(img[data-alignment='center']) {
display: block !important;
margin-right: auto !important;
margin-left: auto !important;
}
.contract-rich-text :deep(img[data-alignment='left']) {
display: block !important;
margin-right: auto !important;
margin-left: 0 !important;
}
.contract-rich-text :deep(img[data-alignment='right']) {
display: block !important;
margin-right: 0 !important;
margin-left: auto !important;
}
/* 没有 data-alignment 的图片默认居中 */
.contract-rich-text :deep(img:not([data-alignment])) {
display: block;
margin-right: auto;
margin-left: auto;
}
.contract-rich-text :deep(hr) {
margin: 1em 0;
border: none;
border-top: 1px solid var(--el-border-color);
}
.contract-rich-text :deep(mark) {
padding: 0 0.2em;
background-color: #ff0;
}
/* 文本对齐 */
.contract-rich-text :deep([style*='text-align: center']),
.contract-rich-text :deep([style*='text-align:center']) {
text-align: center;
}
.contract-rich-text :deep([style*='text-align: right']),
.contract-rich-text :deep([style*='text-align:right']) {
text-align: right;
}
.contract-rich-text :deep([style*='text-align: justify']),
.contract-rich-text :deep([style*='text-align:justify']) {
text-align: justify;
}
.contract-variable {
padding: 0 4px;
}
.contract-variable.variable-filled {
font-weight: 500;
color: var(--el-text-color-primary);
}
.contract-variable.variable-empty {
color: var(--el-text-color-placeholder);
}
.contract-table {
width: 100%;
}
.signature-zone,
.seal-zone {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
border-radius: 4px;
}
.signature-content,
.seal-content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.page-break {
display: flex;
align-items: center;
padding: 16px 0;
}
.page-break-line {
flex: 1;
height: 1px;
background: repeating-linear-gradient(
90deg,
var(--el-border-color) 0,
var(--el-border-color) 4px,
transparent 4px,
transparent 8px
);
}
.page-break-text {
padding: 0 12px;
font-size: 12px;
color: var(--el-text-color-placeholder);
}
.contract-image {
max-width: 100%;
}
/* 富文本中的变量标签样式 - 添加下划线 */
.contract-rich-text :deep(.contract-variable-tag) {
display: inline-block;
padding: 0 6px;
padding-bottom: 2px;
margin: 0 2px;
font-family: monospace;
font-size: inherit;
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
border-bottom: 2px solid var(--el-color-primary);
border-radius: 0;
}
/* 变量替换后的样式 - 添加下划线 */
.contract-rich-text :deep(.variable-replaced) {
padding-bottom: 1px;
font-weight: inherit;
color: inherit;
border-bottom: 1px solid currentcolor;
}
/* 富文本中可编辑变量样式 - 添加下划线 */
.contract-rich-text :deep(.rich-text-variable) {
display: inline-block;
padding: 2px 8px;
padding-bottom: 3px;
margin: 0 2px;
cursor: pointer;
border-bottom: 2px solid var(--el-color-primary);
border-radius: 3px 3px 0 0;
transition: all 0.2s;
}
.contract-rich-text :deep(.rich-text-variable.filled) {
color: var(--el-text-color-primary);
background: var(--el-color-success-light-9);
border-bottom: 2px solid var(--el-color-success);
}
.contract-rich-text :deep(.rich-text-variable.filled:hover) {
background: var(--el-color-success-light-8);
}
.contract-rich-text :deep(.rich-text-variable.empty) {
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
border-bottom: 2px dashed var(--el-color-primary);
}
.contract-rich-text :deep(.rich-text-variable.empty:hover) {
background: var(--el-color-primary-light-8);
}
</style>
@@ -1,538 +0,0 @@
<script setup lang="ts">
import type { ContractElement } from '../store/contractDesignStore';
import { computed, nextTick, ref } from 'vue';
import { ArrowDown, ArrowUp, Copy, Plus, Trash2 } from '@vben/icons';
import {
ElDropdown,
ElDropdownItem,
ElDropdownMenu,
ElIcon,
ElInput,
ElTooltip,
} from 'element-plus';
import RichTextEditor from '#/components/zq-form/rich-text-editor/rich-text-editor.vue';
import {
predefinedVariables,
useContractDesignStore,
} from '../store/contractDesignStore';
import ElementRenderer from './ElementRenderer.vue';
const props = defineProps<{
element: ContractElement;
index: number;
total: number;
}>();
const store = useContractDesignStore();
const isActive = computed(() => store.activeId === props.element.id);
// 编辑状态
const isEditing = ref(false);
const editingContent = ref('');
const inputRef = ref<InstanceType<typeof ElInput> | null>(null);
// 是否可编辑的元素类型
const isEditable = computed(() => {
return ['paragraph', 'rich-text', 'title'].includes(props.element.type);
});
// 富文本编辑内容
const richTextContent = ref('');
// 富文本编辑器实例引用
const richTextEditorRef = ref<any>(null);
// 可用变量列表(预定义 + 模板自定义)
const availableVariables = computed(() => {
const customVars = store.templateConfig.variables || [];
return [...predefinedVariables, ...customVars];
});
const handleSelect = (e: MouseEvent) => {
e.stopPropagation();
store.setActive(props.element.id);
};
// 双击进入编辑模式
const handleDoubleClick = (e: MouseEvent) => {
e.stopPropagation();
if (!isEditable.value) return;
isEditing.value = true;
editingContent.value = props.element.props.content || '';
// 富文本直接进入编辑模式
if (props.element.type === 'rich-text') {
richTextContent.value = props.element.props.content || '';
return;
}
nextTick(() => {
if (inputRef.value) {
inputRef.value.focus();
}
});
};
// 保存富文本
const handleSaveRichText = () => {
// 优先使用组件暴露的 getHTML 方法获取最新内容
let content = richTextContent.value;
if (richTextEditorRef.value) {
// 尝试使用 getHTML 方法
const html = richTextEditorRef.value.getHTML?.();
if (html && html !== '<p></p>') {
content = html;
} else {
// 备用:尝试从编辑器实例获取
const editor = richTextEditorRef.value.getEditor?.();
if (editor) {
const editorHtml = editor.getHTML();
if (editorHtml && editorHtml !== '<p></p>') {
content = editorHtml;
}
}
}
}
// 如果获取到的内容为空或只有空段落,使用原始内容
if (!content || content === '<p></p>') {
content = props.element.props.content || '';
}
store.updateElementProps(props.element.id, {
content,
});
store.recordSnapshot();
isEditing.value = false;
};
// 富文本内容变化
const handleRichTextChange = (content: string) => {
richTextContent.value = content;
};
// 根据 code 获取变量名称
const getVariableName = (code: string) => {
const variable = availableVariables.value.find((v) => v.code === code);
return variable?.name || code;
};
// 插入变量到富文本
const handleInsertVariable = (variableCode: string) => {
// 获取编辑器实例并插入
const editor = richTextEditorRef.value?.getEditor?.();
const variableName = getVariableName(variableCode);
// 使用特殊格式:【变量名称|变量code】,便于后续解析
const variableText = `${variableName}|${variableCode}`;
if (editor) {
// 直接插入文本格式的变量标记
editor.chain().focus().insertContent(variableText).run();
} else {
// 备用方案:直接追加到内容末尾
richTextContent.value += variableText;
}
};
// 保存编辑
const handleSaveEdit = () => {
if (editingContent.value !== props.element.props.content) {
store.updateElementProps(props.element.id, {
content: editingContent.value,
});
store.recordSnapshot();
}
isEditing.value = false;
};
// 取消编辑
const handleCancelEdit = () => {
isEditing.value = false;
editingContent.value = props.element.props.content || '';
};
// 处理键盘事件
const handleKeydown = (e: KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey && props.element.type === 'title') {
e.preventDefault();
handleSaveEdit();
} else if (e.key === 'Escape') {
handleCancelEdit();
}
};
const handleDelete = (e: MouseEvent) => {
e.stopPropagation();
store.deleteElement(props.element.id);
};
const handleCopy = (e: MouseEvent) => {
e.stopPropagation();
store.copyElement(props.element.id);
};
const handleMoveUp = (e: MouseEvent) => {
e.stopPropagation();
store.moveElement(props.element.id, 'up');
};
const handleMoveDown = (e: MouseEvent) => {
e.stopPropagation();
store.moveElement(props.element.id, 'down');
};
// 元素类型标签
const typeLabels: Record<string, string> = {
title: '标题',
paragraph: '段落',
'rich-text': '富文本',
variable: '变量',
table: '表格',
'signature-zone': '签名区',
'seal-zone': '盖章区',
'date-zone': '日期区',
divider: '分割线',
'page-break': '分页符',
image: '图片',
};
</script>
<template>
<div
class="element-wrapper group relative"
:class="{ 'is-active': isActive, 'is-editing': isEditing }"
@click="handleSelect"
@dblclick="handleDoubleClick"
>
<!-- 元素类型标签 -->
<div
v-if="isActive && !isEditing"
class="element-type-label absolute -top-5 left-0 rounded-t bg-[var(--el-color-primary)] px-2 py-0.5 text-xs text-white"
>
{{ typeLabels[element.type] || element.type }}
<span v-if="isEditable" class="ml-1 opacity-70">(双击编辑)</span>
</div>
<!-- 操作按钮 -->
<div
v-show="isActive && !isEditing"
class="element-actions absolute -right-1 -top-1 z-10 flex gap-1 rounded bg-[var(--el-bg-color)] p-1 shadow"
>
<ElTooltip content="上移" placement="top" :show-after="500">
<button
class="action-btn"
:disabled="index === 0"
@click="handleMoveUp"
>
<ElIcon :size="14"><ArrowUp /></ElIcon>
</button>
</ElTooltip>
<ElTooltip content="下移" placement="top" :show-after="500">
<button
class="action-btn"
:disabled="index === total - 1"
@click="handleMoveDown"
>
<ElIcon :size="14"><ArrowDown /></ElIcon>
</button>
</ElTooltip>
<ElTooltip content="复制" placement="top" :show-after="500">
<button class="action-btn" @click="handleCopy">
<ElIcon :size="14"><Copy /></ElIcon>
</button>
</ElTooltip>
<ElTooltip content="删除" placement="top" :show-after="500">
<button class="action-btn action-btn-danger" @click="handleDelete">
<ElIcon :size="14"><Trash2 /></ElIcon>
</button>
</ElTooltip>
</div>
<!-- 编辑模式 -->
<div v-if="isEditing" class="editing-content" @click.stop>
<!-- 标题编辑 -->
<ElInput
v-if="element.type === 'title'"
ref="inputRef"
v-model="editingContent"
placeholder="请输入标题"
:style="{
fontSize: `${element.props.fontSize}px`,
fontWeight: element.props.fontWeight,
textAlign: element.props.align,
}"
@blur="handleSaveEdit"
@keydown="handleKeydown"
/>
<!-- 段落编辑 -->
<ElInput
v-else-if="element.type === 'paragraph'"
ref="inputRef"
v-model="editingContent"
type="textarea"
:rows="4"
placeholder="请输入段落内容"
:autosize="{ minRows: 2, maxRows: 10 }"
@blur="handleSaveEdit"
@keydown="handleKeydown"
/>
<!-- 富文本编辑 -->
<div v-else-if="element.type === 'rich-text'" class="rich-text-editing">
<!-- 变量插入工具栏 -->
<div class="variable-toolbar">
<ElDropdown trigger="click" @command="handleInsertVariable">
<button class="variable-insert-btn">
<ElIcon :size="14"><Plus /></ElIcon>
<span>插入变量</span>
</button>
<template #dropdown>
<ElDropdownMenu>
<div class="variable-dropdown-header">预定义变量</div>
<ElDropdownItem
v-for="v in availableVariables.slice(0, 14)"
:key="v.code"
:command="v.code"
>
<span class="variable-name">{{ v.name }}</span>
<span class="variable-code">{{ v.code }}</span>
</ElDropdownItem>
<template v-if="availableVariables.length > 14">
<div class="variable-dropdown-header">自定义变量</div>
<ElDropdownItem
v-for="v in availableVariables.slice(14)"
:key="v.code"
:command="v.code"
>
<span class="variable-name">{{ v.name }}</span>
<span class="variable-code">{{ v.code }}</span>
</ElDropdownItem>
</template>
</ElDropdownMenu>
</template>
</ElDropdown>
<span class="variable-tip"
>提示插入的变量将在合同签署时自动替换为实际值</span
>
</div>
<RichTextEditor
ref="richTextEditorRef"
v-model="richTextContent"
:min-height="150"
:max-height="400"
placeholder="请输入内容..."
@change="handleRichTextChange"
/>
<div class="mt-2 flex justify-end gap-2">
<button class="edit-btn" @click="handleCancelEdit">取消</button>
<button class="edit-btn edit-btn-primary" @click="handleSaveRichText">
保存
</button>
</div>
</div>
<!-- 标题/段落的保存按钮 -->
<div
v-if="element.type !== 'rich-text'"
class="mt-2 flex justify-end gap-2"
>
<button class="edit-btn" @click="handleCancelEdit">取消</button>
<button class="edit-btn edit-btn-primary" @click="handleSaveEdit">
保存
</button>
</div>
</div>
<!-- 元素内容非编辑模式 -->
<div v-else class="element-content">
<ElementRenderer :element="element" :is-design="true" />
</div>
<!-- 拖拽手柄 -->
<div
v-show="isActive && !isEditing"
class="drag-handle absolute left-0 top-1/2 -translate-x-full -translate-y-1/2 cursor-move rounded-l bg-[var(--el-color-primary)] px-1 py-2 text-white opacity-80"
>
<div class="flex flex-col gap-0.5">
<div class="h-0.5 w-2 rounded bg-white"></div>
<div class="h-0.5 w-2 rounded bg-white"></div>
<div class="h-0.5 w-2 rounded bg-white"></div>
</div>
</div>
</div>
</template>
<style scoped>
.element-wrapper {
position: relative;
padding: 8px;
margin: 4px 0;
cursor: pointer;
border: 1px solid transparent;
border-radius: 4px;
transition: all 0.2s;
}
.element-wrapper:hover {
background-color: var(--el-fill-color-lighter);
border-color: var(--el-border-color);
}
.element-wrapper.is-active {
background-color: var(--el-color-primary-light-9);
border-color: var(--el-color-primary);
}
.action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
color: var(--el-text-color-regular);
cursor: pointer;
background: var(--el-fill-color-light);
border: none;
border-radius: 4px;
transition: all 0.2s;
}
.action-btn:hover:not(:disabled) {
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
}
.action-btn:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.action-btn-danger:hover:not(:disabled) {
color: var(--el-color-danger);
background: var(--el-color-danger-light-9);
}
.element-content {
min-height: 20px;
}
.element-wrapper.is-editing {
background-color: var(--el-bg-color);
border-color: var(--el-color-primary);
}
.editing-content {
padding: 4px;
}
.edit-btn {
padding: 4px 12px;
font-size: 12px;
color: var(--el-text-color-regular);
cursor: pointer;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color);
border-radius: 4px;
transition: all 0.2s;
}
.edit-btn:hover {
color: var(--el-color-primary);
border-color: var(--el-color-primary);
}
.edit-btn-primary {
color: white;
background: var(--el-color-primary);
border-color: var(--el-color-primary);
}
.edit-btn-primary:hover {
color: white;
background: var(--el-color-primary-light-3);
border-color: var(--el-color-primary-light-3);
}
.rich-text-editing {
overflow: hidden;
border: 1px solid var(--el-color-primary);
border-radius: 4px;
}
.rich-text-editing :deep(.rich-text-editor) {
border: none;
}
/* 变量插入工具栏 */
.variable-toolbar {
display: flex;
gap: 12px;
align-items: center;
padding: 8px 12px;
background: var(--el-fill-color-light);
border-bottom: 1px solid var(--el-border-color-light);
}
.variable-insert-btn {
display: flex;
gap: 4px;
align-items: center;
padding: 4px 10px;
font-size: 12px;
color: var(--el-color-primary);
cursor: pointer;
background: var(--el-color-primary-light-9);
border: 1px solid var(--el-color-primary);
border-radius: 4px;
transition: all 0.2s;
}
.variable-insert-btn:hover {
color: white;
background: var(--el-color-primary);
}
.variable-tip {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.variable-dropdown-header {
padding: 6px 12px;
font-size: 12px;
font-weight: 500;
color: var(--el-text-color-secondary);
background: var(--el-fill-color-light);
}
.variable-name {
margin-right: 8px;
}
.variable-code {
font-family: monospace;
font-size: 11px;
color: var(--el-text-color-placeholder);
}
/* 变量标签样式 */
:deep(.contract-variable-tag) {
display: inline-block;
padding: 0 6px;
margin: 0 2px;
font-family: monospace;
font-size: 12px;
color: var(--el-color-primary);
cursor: default;
user-select: none;
background: var(--el-color-primary-light-9);
border: 1px solid var(--el-color-primary-light-5);
border-radius: 3px;
}
</style>
@@ -1,971 +0,0 @@
<script setup lang="ts">
import type {
ContractMaterial,
ContractTemplateConfig,
} from '../store/contractDesignStore';
import { computed, ref } from 'vue';
import {
AlignLeft,
Calendar,
Circle,
Code,
FileText,
Heading1,
Image,
List,
Minus,
Pencil,
Search,
Table,
} from '@vben/icons';
import { $t } from '@vben/locales';
import {
ElIcon,
ElInput,
ElMessage,
ElScrollbar,
ElTabPane,
ElTabs,
} from 'element-plus';
import draggable from 'vuedraggable';
import {
contractMaterials,
useContractDesignStore,
} from '../store/contractDesignStore';
const store = useContractDesignStore();
// 当前 Tab
const activeTab = ref('elements');
const searchKeyword = ref('');
const activeGroups = ref(['text', 'variable', 'signature', 'layout']);
// 图标映射
const iconMap: Record<string, any> = {
Heading1,
AlignLeft,
FileText,
Code,
Table,
Pencil,
Circle,
Calendar,
Minus,
Image,
};
const getIcon = (iconName: string) => {
return iconMap[iconName] || FileText;
};
// 分组材料
const groupedMaterials = computed(() => {
const groups: Record<string, ContractMaterial[]> = {
text: [],
variable: [],
signature: [],
layout: [],
};
contractMaterials.forEach((material) => {
if (groups[material.category]) {
groups[material.category].push(material);
}
});
return groups;
});
// 过滤后的材料
const filteredMaterials = computed(() => {
if (!searchKeyword.value) return groupedMaterials.value;
const keyword = searchKeyword.value.toLowerCase();
const result: Record<string, ContractMaterial[]> = {
text: [],
variable: [],
signature: [],
layout: [],
};
Object.entries(groupedMaterials.value).forEach(([category, materials]) => {
result[category] = materials.filter(
(m) =>
m.title.toLowerCase().includes(keyword) ||
m.type.toLowerCase().includes(keyword),
);
});
return result;
});
const getGroupLabel = (group: string) => {
const labels: Record<string, string> = {
text: $t('contract-design.textElements'),
variable: $t('contract-design.variableElements'),
signature: $t('contract-design.signatureElements'),
layout: $t('contract-design.layoutElements'),
};
return labels[group] || group;
};
const toggleGroup = (group: string) => {
const index = activeGroups.value.indexOf(group);
if (index === -1) {
activeGroups.value.push(group);
} else {
activeGroups.value.splice(index, 1);
}
};
const onDragStart = () => store.setDragging(true);
const onDragEnd = () => store.setDragging(false);
// 点击添加元素
const handleClickAdd = (material: ContractMaterial) => {
store.addElement(material);
};
// 克隆函数
const cloneMaterial = (material: ContractMaterial) => {
return store.cloneElement(material);
};
// 示例模板列表
interface TemplateItem {
id: string;
name: string;
description: string;
category: string;
config: ContractTemplateConfig;
}
const getTemplateCategoryLabel = (key: string) => {
const labels: Record<string, string> = {
common: $t('contract-design.commonContracts'),
hr: $t('contract-design.hrContracts'),
business: $t('contract-design.businessContracts'),
};
return labels[key] || key;
};
// 获取元素标题的国际化文本
const getMaterialTitle = (type: string): string => {
const titleMap: Record<string, string> = {
title: $t('contract-design.contractTitle'),
paragraph: $t('contract-design.paragraphText'),
'rich-text': $t('contract-design.richText'),
variable: $t('contract-design.variablePlaceholder'),
table: $t('contract-design.table'),
'signature-zone': $t('contract-design.signatureZone'),
'seal-zone': $t('contract-design.sealZone'),
'date-zone': $t('contract-design.dateZone'),
divider: $t('contract-design.divider'),
'page-break': $t('contract-design.pageBreak'),
image: $t('contract-design.image'),
};
return titleMap[type] || type;
};
const templateCategories = computed(() => [
{ key: 'common', label: getTemplateCategoryLabel('common') },
{ key: 'hr', label: getTemplateCategoryLabel('hr') },
{ key: 'business', label: getTemplateCategoryLabel('business') },
]);
const sampleTemplates: TemplateItem[] = [
{
id: 'purchase',
name: '采购合同',
description: '标准采购合同模板',
category: 'common',
config: {
id: 'purchase-template',
name: '采购合同',
code: 'PURCHASE_CONTRACT',
category: '常用合同',
description: '标准采购合同模板',
pageSize: 'A4',
pageMargin: { top: 60, right: 60, bottom: 60, left: 60 },
elements: [
{
id: 'title-1',
type: 'title',
props: {
content: '采购合同',
level: 1,
align: 'center',
fontSize: 24,
fontWeight: 'bold',
},
},
{
id: 'rich-1',
type: 'rich-text',
props: {
content: `<p style="text-align: right;">合同编号:{{contract_no}}</p>
<p><br></p>
<p><strong>甲方(采购方):</strong>{{party_a_name}}</p>
<p><strong>地址:</strong>{{party_a_address}}</p>
<p><strong>联系电话:</strong>{{party_a_phone}}</p>
<p><br></p>
<p><strong>乙方(供应方):</strong>{{party_b_name}}</p>
<p><strong>地址:</strong>{{party_b_address}}</p>
<p><strong>联系电话:</strong>{{party_b_phone}}</p>
<p><br></p>
<p>根据《中华人民共和国民法典》及相关法律法规,甲乙双方本着平等互利、诚实信用的原则,经友好协商,就甲方向乙方采购货物事宜达成如下协议:</p>
<p><br></p>
<h3>第一条 采购货物</h3>
<p>甲方向乙方采购货物,详见附件清单。</p>
<p><br></p>
<h3>第二条 合同金额</h3>
<p>本合同总金额为人民币{{contract_amount}}元。</p>
<p><br></p>
<h3>第三条 交货时间及地点</h3>
<p>1. 交货时间:{{start_date}}</p>
<p>2. 交货地点:{{sign_location}}</p>
<p><br></p>
<h3>第四条 付款方式</h3>
<p>合同签订后{{payment_days}}日内支付全款。</p>
<p><br></p>
<h3>第五条 验收标准</h3>
<p>货物到达后,甲方应在7日内完成验收。如有质量问题,应在验收期内书面通知乙方。</p>
<p><br></p>
<h3>第六条 违约责任</h3>
<p>任何一方违反本合同约定,应承担违约责任,赔偿对方因此遭受的损失。</p>
<p><br></p>
<h3>第七条 争议解决</h3>
<p>本合同在履行过程中发生争议,双方应协商解决;协商不成的,可向甲方所在地人民法院提起诉讼。</p>
<p><br></p>
<p>本合同一式两份,甲乙双方各执一份,自双方签字盖章之日起生效。</p>
<p><br></p>
<p>签订日期:{{sign_date}}</p>`,
minHeight: 100,
padding: 8,
showBorder: false,
},
},
{
id: 'sig-a',
type: 'signature-zone',
props: { width: 200, height: 80, label: '甲方签章' },
signature: {
partyType: 'party_a',
partyLabel: '甲方',
showDate: true,
showSeal: false,
required: true,
},
},
{
id: 'sig-b',
type: 'signature-zone',
props: { width: 200, height: 80, label: '乙方签章' },
signature: {
partyType: 'party_b',
partyLabel: '乙方',
showDate: true,
showSeal: false,
required: true,
},
},
],
variables: [
{
code: 'payment_days',
name: '付款天数',
type: 'number',
required: true,
},
],
parties: [
{ type: 'party_a', label: '甲方(采购方)', required: true },
{ type: 'party_b', label: '乙方(供应方)', required: true },
],
},
},
{
id: 'labor',
name: '劳动合同',
description: '标准劳动合同模板',
category: 'hr',
config: {
id: 'labor-template',
name: '劳动合同',
code: 'LABOR_CONTRACT',
category: '人事合同',
description: '标准劳动合同模板',
pageSize: 'A4',
pageMargin: { top: 60, right: 60, bottom: 60, left: 60 },
elements: [
{
id: 'title-1',
type: 'title',
props: {
content: '劳动合同书',
level: 1,
align: 'center',
fontSize: 26,
fontWeight: 'bold',
},
},
{
id: 'rich-1',
type: 'rich-text',
props: {
content: `<p style="text-align: center;"><br></p>
<p><strong>甲方(用人单位):</strong>{{party_a_name}}</p>
<p><strong>法定代表人:</strong>{{party_a_legal_person}}</p>
<p><strong>地址:</strong>{{party_a_address}}</p>
<p><br></p>
<p><strong>乙方(劳动者):</strong>{{party_b_name}}</p>
<p><strong>身份证号码:</strong>{{employee_id_card}}</p>
<p><strong>联系电话:</strong>{{party_b_phone}}</p>
<p><br></p>
<p>根据《中华人民共和国劳动法》、《中华人民共和国劳动合同法》及相关法律法规,甲乙双方本着平等自愿、协商一致的原则,签订本劳动合同。</p>
<p><br></p>
<h3>第一条 合同期限</h3>
<p>本合同为{{contract_term_type}}期限劳动合同。</p>
<p>合同期限自{{start_date}}起至{{end_date}}止。</p>
<p><br></p>
<h3>第二条 工作内容和工作地点</h3>
<p>1. 乙方同意根据甲方工作需要,担任{{job_position}}岗位工作。</p>
<p>2. 工作地点:{{work_location}}</p>
<p><br></p>
<h3>第三条 工作时间和休息休假</h3>
<p>甲方安排乙方执行标准工时制度。</p>
<p><br></p>
<h3>第四条 劳动报酬</h3>
<p>乙方月工资为人民币{{monthly_salary}}元。</p>
<p><br></p>
<h3>第五条 社会保险和福利待遇</h3>
<p>甲方依法为乙方缴纳社会保险费,乙方应缴纳的部分由甲方从乙方工资中代扣代缴。</p>
<p><br></p>
<h3>第六条 劳动保护和劳动条件</h3>
<p>甲方为乙方提供符合国家规定的劳动安全卫生条件和必要的劳动防护用品。</p>
<p><br></p>
<h3>第七条 合同的变更、解除和终止</h3>
<p>按照《劳动合同法》相关规定执行。</p>
<p><br></p>
<p>本合同一式两份,甲乙双方各执一份,自双方签字盖章之日起生效。</p>
<p><br></p>
<p>签订日期:{{sign_date}}</p>`,
minHeight: 100,
padding: 8,
showBorder: false,
},
},
{
id: 'sig-a',
type: 'signature-zone',
props: { width: 200, height: 80, label: '甲方(盖章)' },
signature: {
partyType: 'party_a',
partyLabel: '甲方',
showDate: true,
showSeal: false,
required: true,
},
},
{
id: 'sig-b',
type: 'signature-zone',
props: { width: 200, height: 80, label: '乙方(签名)' },
signature: {
partyType: 'party_b',
partyLabel: '乙方',
showDate: true,
showSeal: false,
required: true,
},
},
],
variables: [
{
code: 'employee_id_card',
name: '员工身份证号',
type: 'text',
required: true,
},
{
code: 'contract_term_type',
name: '合同期限类型',
type: 'text',
required: true,
},
{
code: 'job_position',
name: '工作岗位',
type: 'text',
required: true,
},
{
code: 'work_location',
name: '工作地点',
type: 'text',
required: true,
},
{
code: 'monthly_salary',
name: '月工资',
type: 'money',
required: true,
},
],
parties: [
{ type: 'party_a', label: '甲方(用人单位)', required: true },
{ type: 'party_b', label: '乙方(劳动者)', required: true },
],
},
},
{
id: 'nda',
name: '保密协议',
description: '员工保密协议模板',
category: 'hr',
config: {
id: 'nda-template',
name: '保密协议',
code: 'NDA_CONTRACT',
category: '人事合同',
description: '员工保密协议模板',
pageSize: 'A4',
pageMargin: { top: 60, right: 60, bottom: 60, left: 60 },
elements: [
{
id: 'title-1',
type: 'title',
props: {
content: '保密协议',
level: 1,
align: 'center',
fontSize: 24,
fontWeight: 'bold',
},
},
{
id: 'rich-1',
type: 'rich-text',
props: {
content: `<p style="text-align: right;">协议编号:{{contract_no}}</p>
<p><br></p>
<p><strong>甲方:</strong>{{party_a_name}}</p>
<p><strong>地址:</strong>{{party_a_address}}</p>
<p><br></p>
<p><strong>乙方:</strong>{{party_b_name}}</p>
<p><strong>身份证号:</strong>{{employee_id_card}}</p>
<p><br></p>
<p>鉴于乙方在甲方任职期间,将接触或知悉甲方的商业秘密和保密信息,为保护甲方的合法权益,双方本着平等自愿、诚实信用的原则,签订本协议。</p>
<p><br></p>
<h3>第一条 保密信息的范围</h3>
<p>本协议所称保密信息包括但不限于:技术信息、经营信息及甲方明确要求保密的其他信息。</p>
<p><br></p>
<h3>第二条 保密义务</h3>
<p>1. 乙方应对保密信息严格保密,未经甲方书面同意,不得向任何第三方披露。</p>
<p>2. 乙方不得将保密信息用于本职工作以外的任何目的。</p>
<p>3. 乙方离职时,应将所有保密信息及载体归还甲方。</p>
<p><br></p>
<h3>第三条 保密期限</h3>
<p>乙方的保密义务自本协议签订之日起生效,保密期限为{{confidentiality_years}}年。</p>
<p><br></p>
<h3>第四条 违约责任</h3>
<p>乙方违反本协议约定的,应向甲方支付违约金人民币{{penalty_amount}}元,并赔偿甲方因此遭受的全部损失。</p>
<p><br></p>
<p>本协议一式两份,甲乙双方各执一份,自双方签字盖章之日起生效。</p>
<p><br></p>
<p>签订日期:{{sign_date}}</p>`,
minHeight: 100,
padding: 8,
showBorder: false,
},
},
{
id: 'sig-a',
type: 'signature-zone',
props: { width: 200, height: 80, label: '甲方(盖章)' },
signature: {
partyType: 'party_a',
partyLabel: '甲方',
showDate: true,
showSeal: false,
required: true,
},
},
{
id: 'sig-b',
type: 'signature-zone',
props: { width: 200, height: 80, label: '乙方(签名)' },
signature: {
partyType: 'party_b',
partyLabel: '乙方',
showDate: true,
showSeal: false,
required: true,
},
},
],
variables: [
{
code: 'employee_id_card',
name: '员工身份证号',
type: 'text',
required: true,
},
{
code: 'confidentiality_years',
name: '保密期限(年)',
type: 'number',
required: true,
},
{
code: 'penalty_amount',
name: '违约金金额',
type: 'money',
required: true,
},
],
parties: [
{ type: 'party_a', label: '甲方', required: true },
{ type: 'party_b', label: '乙方', required: true },
],
},
},
{
id: 'service',
name: '服务合同',
description: '通用服务合同模板',
category: 'business',
config: {
id: 'service-template',
name: '服务合同',
code: 'SERVICE_CONTRACT',
category: '商务合同',
description: '通用服务合同模板',
pageSize: 'A4',
pageMargin: { top: 60, right: 60, bottom: 60, left: 60 },
elements: [
{
id: 'title-1',
type: 'title',
props: {
content: '服务合同',
level: 1,
align: 'center',
fontSize: 24,
fontWeight: 'bold',
},
},
{
id: 'rich-1',
type: 'rich-text',
props: {
content: `<p style="text-align: right;">合同编号:{{contract_no}}</p>
<p><br></p>
<p><strong>甲方(委托方):</strong>{{party_a_name}}</p>
<p><strong>地址:</strong>{{party_a_address}}</p>
<p><strong>联系电话:</strong>{{party_a_phone}}</p>
<p><br></p>
<p><strong>乙方(服务方):</strong>{{party_b_name}}</p>
<p><strong>地址:</strong>{{party_b_address}}</p>
<p><strong>联系电话:</strong>{{party_b_phone}}</p>
<p><br></p>
<p>根据《中华人民共和国民法典》及相关法律法规,甲乙双方本着平等互利、诚实信用的原则,就甲方委托乙方提供服务事宜达成如下协议:</p>
<p><br></p>
<h3>第一条 服务内容</h3>
<p>乙方为甲方提供以下服务:{{service_content}}</p>
<p><br></p>
<h3>第二条 服务期限</h3>
<p>服务期限自{{start_date}}起至{{end_date}}止。</p>
<p><br></p>
<h3>第三条 服务费用及支付方式</h3>
<p>1. 服务费用总计人民币{{contract_amount}}元。</p>
<p>2. 支付方式:{{payment_method}}</p>
<p><br></p>
<h3>第四条 双方权利义务</h3>
<p><strong>甲方权利义务:</strong>按时支付服务费用;提供乙方完成服务所需的必要配合。</p>
<p><strong>乙方权利义务:</strong>按照约定提供服务;保证服务质量符合约定标准。</p>
<p><br></p>
<h3>第五条 违约责任</h3>
<p>任何一方违反本合同约定,应承担违约责任,赔偿对方因此遭受的损失。</p>
<p><br></p>
<h3>第六条 争议解决</h3>
<p>本合同在履行过程中发生争议,双方应协商解决;协商不成的,可向甲方所在地人民法院提起诉讼。</p>
<p><br></p>
<p>本合同一式两份,甲乙双方各执一份,自双方签字盖章之日起生效。</p>
<p><br></p>
<p>签订日期:{{sign_date}}</p>`,
minHeight: 100,
padding: 8,
showBorder: false,
},
},
{
id: 'sig-a',
type: 'signature-zone',
props: { width: 200, height: 80, label: '甲方签章' },
signature: {
partyType: 'party_a',
partyLabel: '甲方',
showDate: true,
showSeal: false,
required: true,
},
},
{
id: 'sig-b',
type: 'signature-zone',
props: { width: 200, height: 80, label: '乙方签章' },
signature: {
partyType: 'party_b',
partyLabel: '乙方',
showDate: true,
showSeal: false,
required: true,
},
},
],
variables: [
{
code: 'service_content',
name: '服务内容',
type: 'text',
required: true,
},
{
code: 'payment_method',
name: '支付方式',
type: 'text',
required: true,
},
],
parties: [
{ type: 'party_a', label: '甲方(委托方)', required: true },
{ type: 'party_b', label: '乙方(服务方)', required: true },
],
},
},
{
id: 'rental',
name: '租赁合同',
description: '房屋/设备租赁合同模板',
category: 'business',
config: {
id: 'rental-template',
name: '租赁合同',
code: 'RENTAL_CONTRACT',
category: '商务合同',
description: '租赁合同模板',
pageSize: 'A4',
pageMargin: { top: 60, right: 60, bottom: 60, left: 60 },
elements: [
{
id: 'title-1',
type: 'title',
props: {
content: '租赁合同',
level: 1,
align: 'center',
fontSize: 24,
fontWeight: 'bold',
},
},
{
id: 'rich-1',
type: 'rich-text',
props: {
content: `<p style="text-align: right;">合同编号:{{contract_no}}</p>
<p><br></p>
<p><strong>出租方(甲方):</strong>{{party_a_name}}</p>
<p><strong>联系电话:</strong>{{party_a_phone}}</p>
<p><br></p>
<p><strong>承租方(乙方):</strong>{{party_b_name}}</p>
<p><strong>联系电话:</strong>{{party_b_phone}}</p>
<p><br></p>
<p>根据《中华人民共和国民法典》及相关法律法规,甲乙双方在平等、自愿的基础上,就租赁事宜达成如下协议:</p>
<p><br></p>
<h3>第一条 租赁物</h3>
<p>甲方将位于{{rental_location}}的{{rental_property}}出租给乙方使用。</p>
<p><br></p>
<h3>第二条 租赁期限</h3>
<p>租赁期限自{{start_date}}起至{{end_date}}止。</p>
<p><br></p>
<h3>第三条 租金及支付方式</h3>
<p>1. 租金为人民币{{monthly_rent}}元/月。</p>
<p>2. 支付方式:{{payment_cycle}}</p>
<p><br></p>
<h3>第四条 押金</h3>
<p>乙方应于签订本合同时向甲方支付押金人民币{{deposit_amount}}元。租赁期满,乙方无违约行为且租赁物完好的,甲方应在15日内退还押金。</p>
<p><br></p>
<h3>第五条 租赁物的使用和维护</h3>
<p>1. 乙方应按约定用途使用租赁物,不得擅自改变用途。</p>
<p>2. 乙方应妥善保管和使用租赁物,如有损坏应照价赔偿。</p>
<p><br></p>
<h3>第六条 违约责任</h3>
<p>任何一方违反本合同约定,应承担违约责任,赔偿对方因此遭受的损失。</p>
<p><br></p>
<p>本合同一式两份,甲乙双方各执一份,自双方签字盖章之日起生效。</p>
<p><br></p>
<p>签订日期:{{sign_date}}</p>`,
minHeight: 100,
padding: 8,
showBorder: false,
},
},
{
id: 'sig-a',
type: 'signature-zone',
props: { width: 200, height: 80, label: '甲方(出租方)' },
signature: {
partyType: 'party_a',
partyLabel: '甲方',
showDate: true,
showSeal: false,
required: true,
},
},
{
id: 'sig-b',
type: 'signature-zone',
props: { width: 200, height: 80, label: '乙方(承租方)' },
signature: {
partyType: 'party_b',
partyLabel: '乙方',
showDate: true,
showSeal: false,
required: true,
},
},
],
variables: [
{
code: 'rental_location',
name: '租赁物地址',
type: 'text',
required: true,
},
{
code: 'rental_property',
name: '租赁物名称',
type: 'text',
required: true,
},
{ code: 'monthly_rent', name: '月租金', type: 'money', required: true },
{
code: 'payment_cycle',
name: '支付周期',
type: 'text',
required: true,
},
{
code: 'deposit_amount',
name: '押金金额',
type: 'money',
required: true,
},
],
parties: [
{ type: 'party_a', label: '甲方(出租方)', required: true },
{ type: 'party_b', label: '乙方(承租方)', required: true },
],
},
},
];
// 按分类过滤模板
const getTemplatesByCategory = (category: string) => {
return sampleTemplates.filter((t) => t.category === category);
};
// 加载模板
const loadTemplate = (template: TemplateItem) => {
// 深拷贝配置,避免修改原始数据
const config = JSON.parse(JSON.stringify(template.config));
store.importConfig(JSON.stringify(config));
ElMessage.success(`已加载模板:${template.name}`);
activeTab.value = 'elements';
};
</script>
<template>
<div
class="material-panel flex h-full w-64 flex-col rounded border border-[var(--el-border-color)] bg-[var(--el-bg-color)]"
>
<!-- Tab 切换 -->
<ElTabs v-model="activeTab" class="material-tabs flex-1">
<!-- 合同元素 Tab -->
<ElTabPane
:label="$t('contract-design.contractElements')"
name="elements"
class="h-full"
>
<div class="flex h-full flex-col">
<!-- 搜索框 -->
<div class="flex-shrink-0 p-3">
<ElInput
v-model="searchKeyword"
:placeholder="$t('contract-design.searchElements')"
size="small"
clearable
:prefix-icon="Search"
/>
</div>
<!-- 元素列表 -->
<ElScrollbar class="flex-1">
<div class="px-3 pb-3">
<template
v-for="(materials, category) in filteredMaterials"
:key="category"
>
<div v-if="materials.length > 0" class="component-group mb-4">
<!-- 分组标题 -->
<div
class="group-title mb-2 flex cursor-pointer select-none items-center justify-between text-xs text-[var(--el-text-color-regular)] hover:text-[var(--el-color-primary)]"
@click="toggleGroup(category)"
>
<span class="font-bold">{{ getGroupLabel(category) }}</span>
<ElIcon
class="h-4 w-4 transition-transform"
:class="{
'rotate-180': !activeGroups.includes(category),
}"
>
<Minus />
</ElIcon>
</div>
<!-- 元素网格 -->
<div v-show="activeGroups.includes(category)">
<draggable
:list="materials"
:group="{
name: 'contract-design',
pull: 'clone',
put: false,
}"
:sort="false"
:clone="cloneMaterial"
item-key="type"
class="grid grid-cols-2 gap-2"
@start="onDragStart"
@end="onDragEnd"
>
<template #item="{ element }">
<div
class="component-item flex cursor-move flex-col items-center justify-center rounded border border-[var(--el-border-color)] bg-[var(--el-fill-color-light)] p-3 transition-colors hover:border-[var(--el-color-primary)] hover:text-[var(--el-color-primary)]"
@click.stop="handleClickAdd(element)"
>
<ElIcon class="mb-1.5" :size="20">
<component :is="getIcon(element.icon)" />
</ElIcon>
<span class="text-xs">{{
getMaterialTitle(element.type)
}}</span>
</div>
</template>
</draggable>
</div>
</div>
</template>
</div>
</ElScrollbar>
<!-- 底部提示 -->
<div
class="flex-shrink-0 border-t border-[var(--el-border-color)] px-3 py-2"
>
<div
class="text-center text-xs text-[var(--el-text-color-placeholder)]"
>
<List class="mr-1 inline-block h-3 w-3" />
{{ $t('contract-design.dragOrClickAddElements') }}
</div>
</div>
</div>
</ElTabPane>
<!-- 示例模板 Tab -->
<ElTabPane
:label="$t('contract-design.sampleTemplates')"
name="templates"
class="h-full"
>
<ElScrollbar class="h-full">
<div class="p-3">
<div class="mb-3 text-xs text-[var(--el-text-color-secondary)]">
{{ $t('contract-design.selectTemplateToStart') }}
</div>
<template v-for="cat in templateCategories" :key="cat.key">
<div class="mb-4">
<div
class="mb-2 text-xs font-bold text-[var(--el-text-color-primary)]"
>
{{ cat.label }}
</div>
<div class="space-y-2">
<div
v-for="template in getTemplatesByCategory(cat.key)"
:key="template.id"
class="template-item cursor-pointer rounded border border-[var(--el-border-color)] p-3 transition-all hover:border-[var(--el-color-primary)] hover:bg-[var(--el-color-primary-light-9)]"
@click="loadTemplate(template)"
>
<div
class="mb-1 text-sm font-medium text-[var(--el-text-color-primary)]"
>
{{ template.name }}
</div>
<div class="text-xs text-[var(--el-text-color-secondary)]">
{{ template.description }}
</div>
</div>
</div>
</div>
</template>
</div>
</ElScrollbar>
</ElTabPane>
</ElTabs>
</div>
</template>
<style scoped>
.component-item {
min-height: 60px;
}
.material-panel {
overflow: hidden;
}
.material-tabs {
display: flex;
flex-direction: column;
height: 100%;
}
:deep(.el-tabs__header) {
padding: 0 12px;
margin: 0;
border-bottom: 1px solid var(--el-border-color);
}
:deep(.el-tabs__content) {
flex: 1;
height: 0;
overflow: hidden;
}
:deep(.el-tab-pane) {
height: 100%;
}
.template-item:active {
transform: scale(0.98);
}
</style>
@@ -1,284 +0,0 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue';
import { Download, Eye } from '@vben/icons';
import { $t } from '@vben/locales';
import {
ElButton,
ElDialog,
ElIcon,
ElMessage,
ElScrollbar,
} from 'element-plus';
import { storeToRefs } from 'pinia';
import { useContractDesignStore } from '../store/contractDesignStore';
import { exportPagesToPdf } from '../utils/exportPdf';
import ElementRenderer from './ElementRenderer.vue';
const props = defineProps<{
visible: boolean;
}>();
const emit = defineEmits<{
(e: 'update:visible', value: boolean): void;
}>();
const store = useContractDesignStore();
const { templateConfig } = storeToRefs(store);
const dialogVisible = computed({
get: () => props.visible,
set: (val) => emit('update:visible', val),
});
// 导出中状态
const exporting = ref(false);
// 页面元素引用数组
const pageRefs = ref<HTMLElement[]>([]);
// 隐藏的测量容器引用
const measureContainerRef = ref<HTMLElement | null>(null);
// 页面尺寸映射
const pageSizeMap = {
A4: { width: 794, height: 1123 },
A5: { width: 559, height: 794 },
Letter: { width: 816, height: 1056 },
};
// 页面数量
const pageCount = ref(1);
// 单页样式(完整页面,包含边距)
const pageStyle = computed(() => {
const size = pageSizeMap[templateConfig.value.pageSize];
const margin = templateConfig.value.pageMargin;
return {
width: `${size.width}px`,
height: `${size.height}px`,
padding: `${margin.top}px ${margin.right}px ${margin.bottom}px ${margin.left}px`,
};
});
// 内容区域高度(不含边距)
const contentHeight = computed(() => {
const size = pageSizeMap[templateConfig.value.pageSize];
const margin = templateConfig.value.pageMargin;
return size.height - margin.top - margin.bottom;
});
// 计算页数
const calculatePages = () => {
nextTick(() => {
if (!measureContainerRef.value) return;
const totalHeight = measureContainerRef.value.scrollHeight;
const pageContentHeight = contentHeight.value;
pageCount.value = Math.max(1, Math.ceil(totalHeight / pageContentHeight));
});
};
// 设置页面引用
const setPageRef = (el: any, index: number) => {
if (el) {
pageRefs.value[index] = el;
}
};
// 监听弹窗打开时计算页数
watch(dialogVisible, (val) => {
if (val) {
pageRefs.value = [];
nextTick(() => {
calculatePages();
});
}
});
// 导出 PDF
const handleExportPdf = async () => {
if (pageRefs.value.length === 0) {
ElMessage.error($t('contract-design.exportFailed'));
return;
}
exporting.value = true;
try {
// 获取所有页面元素
const pages = pageRefs.value.filter(Boolean);
await exportPagesToPdf(pages, {
filename: `${templateConfig.value.name || $t('contract-design.contract')}-${Date.now()}.pdf`,
pageSize: templateConfig.value.pageSize,
});
ElMessage.success($t('contract-design.exportSuccess'));
} catch (error) {
console.error('Export PDF failed:', error);
ElMessage.error($t('contract-design.exportFailed'));
} finally {
exporting.value = false;
}
};
</script>
<template>
<ElDialog
v-model="dialogVisible"
:title="$t('contract-design.contractPreview')"
width="900px"
:close-on-click-modal="false"
destroy-on-close
>
<template #header>
<div class="flex items-center gap-2">
<ElIcon :size="18" class="text-[var(--el-color-primary)]">
<Eye />
</ElIcon>
<span
>{{ $t('contract-design.contractPreview') }} -
{{ templateConfig.name }}</span
>
<span
v-if="pageCount > 1"
class="ml-2 rounded bg-[var(--el-color-primary-light-9)] px-2 py-0.5 text-xs text-[var(--el-color-primary)]"
>
{{ $t('contract-design.totalPages', { count: pageCount }) }}
</span>
</div>
</template>
<ElScrollbar max-height="70vh">
<div class="preview-pages-container bg-[var(--el-fill-color-light)] p-6">
<!-- 隐藏的测量容器用于计算总高度 -->
<div
ref="measureContainerRef"
class="measure-container"
:style="{
width: `${pageSizeMap[templateConfig.pageSize].width - templateConfig.pageMargin.left - templateConfig.pageMargin.right}px`,
}"
>
<div
v-for="element in templateConfig.elements"
:key="`measure-${element.id}`"
class="preview-element"
>
<ElementRenderer :element="element" :is-design="false" />
</div>
</div>
<!-- 多页显示 -->
<div class="preview-pages flex flex-col items-center gap-6">
<template v-if="templateConfig.elements.length > 0">
<div
v-for="page in pageCount"
:key="page"
class="preview-page relative"
>
<!-- 页码标签 -->
<div class="page-number">
{{
$t('contract-design.pageLabel', {
current: page,
total: pageCount,
})
}}
</div>
<!-- 页面内容完整的一页包含边距 -->
<div
:ref="(el) => setPageRef(el, page - 1)"
class="preview-paper bg-[var(--el-bg-color)] shadow-lg"
:style="pageStyle"
>
<div class="preview-content-wrapper">
<div
class="preview-content"
:style="{
marginTop: `-${(page - 1) * contentHeight}px`,
}"
>
<div
v-for="element in templateConfig.elements"
:key="`page-${page}-${element.id}`"
class="preview-element"
>
<ElementRenderer :element="element" :is-design="false" />
</div>
</div>
</div>
</div>
</div>
</template>
<div
v-else
:ref="(el) => setPageRef(el, 0)"
class="preview-paper flex items-center justify-center bg-[var(--el-bg-color)] shadow-lg"
:style="pageStyle"
>
<span class="text-[var(--el-text-color-placeholder)]">{{
$t('contract-design.noContent')
}}</span>
</div>
</div>
</div>
</ElScrollbar>
<template #footer>
<ElButton @click="dialogVisible = false">
{{ $t('contract-design.close') }}
</ElButton>
<ElButton type="primary" :loading="exporting" @click="handleExportPdf">
<ElIcon v-if="!exporting" class="mr-1"><Download /></ElIcon>
{{
exporting
? $t('contract-design.exporting')
: $t('contract-design.exportPdf')
}}
</ElButton>
</template>
</ElDialog>
</template>
<style scoped>
.preview-element {
margin-bottom: 4px;
}
.preview-page {
position: relative;
}
.preview-paper {
box-sizing: border-box;
overflow: hidden;
}
.preview-content-wrapper {
width: 100%;
height: 100%;
overflow: hidden;
}
.page-number {
position: absolute;
top: -24px;
right: 0;
padding: 2px 8px;
font-size: 12px;
color: var(--el-text-color-secondary);
background: var(--el-bg-color);
border-radius: 4px;
}
/* 隐藏测量容器 */
.measure-container {
position: absolute;
top: 0;
left: -9999px;
visibility: hidden;
}
</style>
@@ -1,254 +0,0 @@
<script setup lang="ts">
import type { UploadFile } from 'element-plus';
import { computed, ref, watch } from 'vue';
import { Check, Pencil, Upload, X } from '@vben/icons';
import { $t } from '@vben/locales';
import {
ElButton,
ElDialog,
ElIcon,
ElMessage,
ElTabPane,
ElTabs,
ElUpload,
} from 'element-plus';
import SignaturePad from './SignaturePad.vue';
const props = defineProps<{
height?: number;
partyLabel?: string;
title?: string;
visible: boolean;
width?: number;
}>();
const emit = defineEmits<{
(e: 'update:visible', value: boolean): void;
(e: 'confirm', signatureData: string): void;
(e: 'cancel'): void;
}>();
const dialogVisible = computed({
get: () => props.visible,
set: (val) => emit('update:visible', val),
});
// 当前选中的 Tab
const activeTab = ref('draw');
// 签名板引用
const signaturePadRef = ref<InstanceType<typeof SignaturePad> | null>(null);
// 手写签名数据
const drawSignature = ref('');
// 上传的签名图片
const uploadedSignature = ref('');
// 重置状态
const resetState = () => {
activeTab.value = 'draw';
drawSignature.value = '';
uploadedSignature.value = '';
};
// 监听弹窗打开
watch(dialogVisible, (val) => {
if (val) {
resetState();
}
});
// 处理图片上传
const handleUploadChange = (file: UploadFile) => {
if (!file.raw) return;
// 检查文件类型
const isImage = file.raw.type.startsWith('image/');
if (!isImage) {
ElMessage.error($t('contract-design.uploadImageFileOnly'));
return;
}
// 检查文件大小(最大 2MB
const isLt2M = file.raw.size / 1024 / 1024 < 2;
if (!isLt2M) {
ElMessage.error($t('contract-design.imageSizeExceeded'));
return;
}
// 读取图片为 base64
const reader = new FileReader();
reader.addEventListener('load', (e) => {
uploadedSignature.value = e.target?.result as string;
});
reader.readAsDataURL(file.raw);
};
// 确认签名
const handleConfirm = () => {
let signatureData = '';
if (activeTab.value === 'draw') {
// 手写签名
if (signaturePadRef.value?.isEmpty()) {
ElMessage.warning($t('contract-design.pleaseSignFirst'));
return;
}
signatureData = signaturePadRef.value?.getSignatureData() || '';
} else {
// 上传签名
if (!uploadedSignature.value) {
ElMessage.warning($t('contract-design.pleaseUploadSignatureImage'));
return;
}
signatureData = uploadedSignature.value;
}
emit('confirm', signatureData);
dialogVisible.value = false;
};
// 取消
const handleCancel = () => {
emit('cancel');
dialogVisible.value = false;
};
// 清空手写签名
const clearDrawSignature = () => {
signaturePadRef.value?.clear();
drawSignature.value = '';
};
// 清空上传签名
const clearUploadSignature = () => {
uploadedSignature.value = '';
};
</script>
<template>
<ElDialog
v-model="dialogVisible"
:title="title || $t('contract-design.signature')"
width="500px"
:close-on-click-modal="false"
destroy-on-close
>
<template #header>
<div class="flex items-center gap-2">
<ElIcon :size="18" class="text-[var(--el-color-primary)]">
<Pencil />
</ElIcon>
<span>{{
partyLabel
? `${partyLabel} - ${$t('contract-design.signature')}`
: $t('contract-design.signature')
}}</span>
</div>
</template>
<ElTabs v-model="activeTab" class="signature-tabs">
<!-- 手写签名 -->
<ElTabPane name="draw">
<template #label>
<div class="flex items-center gap-1">
<ElIcon :size="14"><Pencil /></ElIcon>
<span>{{ $t('contract-design.drawSignature') }}</span>
</div>
</template>
<div class="flex flex-col items-center">
<SignaturePad
ref="signaturePadRef"
v-model="drawSignature"
:width="props.width || 400"
:height="props.height || 150"
/>
</div>
</ElTabPane>
<!-- 上传签名 -->
<ElTabPane name="upload">
<template #label>
<div class="flex items-center gap-1">
<ElIcon :size="14"><Upload /></ElIcon>
<span>{{ $t('contract-design.uploadSignature') }}</span>
</div>
</template>
<div class="flex flex-col items-center">
<div v-if="uploadedSignature" class="uploaded-preview relative mb-4">
<img
:src="uploadedSignature"
:alt="$t('contract-design.signaturePreview')"
class="max-h-[150px] max-w-[400px] rounded border border-[var(--el-border-color)]"
/>
<ElButton
type="danger"
size="small"
circle
class="absolute -right-2 -top-2"
@click="clearUploadSignature"
>
<ElIcon><X /></ElIcon>
</ElButton>
</div>
<ElUpload
v-else
class="signature-uploader"
:show-file-list="false"
:auto-upload="false"
accept="image/*"
@change="handleUploadChange"
>
<div
class="upload-area flex h-[150px] w-[400px] flex-col items-center justify-center rounded border-2 border-dashed border-[var(--el-border-color)] bg-[var(--el-fill-color-light)] transition-colors hover:border-[var(--el-color-primary)]"
>
<ElIcon
:size="32"
class="mb-2 text-[var(--el-text-color-placeholder)]"
>
<Upload />
</ElIcon>
<span class="text-sm text-[var(--el-text-color-secondary)]">
{{ $t('contract-design.clickOrDragToUpload') }}
</span>
<span
class="mt-1 text-xs text-[var(--el-text-color-placeholder)]"
>
{{ $t('contract-design.supportedFormatsAndSize') }}
</span>
</div>
</ElUpload>
</div>
</ElTabPane>
</ElTabs>
<template #footer>
<ElButton @click="handleCancel">
<ElIcon class="mr-1"><X /></ElIcon>
{{ $t('contract-design.cancel') }}
</ElButton>
<ElButton type="primary" @click="handleConfirm">
<ElIcon class="mr-1"><Check /></ElIcon>
{{ $t('contract-design.confirmSignature') }}
</ElButton>
</template>
</ElDialog>
</template>
<style scoped>
.signature-tabs {
margin-top: -10px;
}
:deep(.el-tabs__content) {
padding-top: 16px;
}
</style>
@@ -1,283 +0,0 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref, watch } from 'vue';
import { Eraser, RotateCcw } from '@vben/icons';
import { $t } from '@vben/locales';
import { ElButton, ElIcon } from 'element-plus';
const props = withDefaults(
defineProps<{
backgroundColor?: string;
disabled?: boolean;
height?: number;
lineColor?: string;
lineWidth?: number;
modelValue?: string;
width?: number;
}>(),
{
width: 400,
height: 200,
lineWidth: 2,
lineColor: '#000000',
backgroundColor: '#ffffff',
disabled: false,
modelValue: '',
},
);
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void;
(e: 'change', value: string): void;
}>();
const canvasRef = ref<HTMLCanvasElement | null>(null);
const isDrawing = ref(false);
const hasSignature = ref(false);
let ctx: CanvasRenderingContext2D | null = null;
let lastX = 0;
let lastY = 0;
// 初始化画布
const initCanvas = () => {
if (!canvasRef.value) return;
ctx = canvasRef.value.getContext('2d');
if (!ctx) return;
// 设置画布大小
canvasRef.value.width = props.width;
canvasRef.value.height = props.height;
// 设置背景
ctx.fillStyle = props.backgroundColor;
ctx.fillRect(0, 0, props.width, props.height);
// 设置画笔样式
ctx.strokeStyle = props.lineColor;
ctx.lineWidth = props.lineWidth;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
// 如果有初始值,加载图片
if (props.modelValue) {
loadImage(props.modelValue);
}
};
// 加载图片
const loadImage = (dataUrl: string) => {
if (!ctx || !canvasRef.value) return;
const img = new Image();
img.addEventListener('load', () => {
ctx!.fillStyle = props.backgroundColor;
ctx!.fillRect(0, 0, props.width, props.height);
ctx!.drawImage(img, 0, 0);
hasSignature.value = true;
});
img.src = dataUrl;
};
// 获取坐标
const getCoordinates = (e: MouseEvent | TouchEvent) => {
if (!canvasRef.value) return { x: 0, y: 0 };
const rect = canvasRef.value.getBoundingClientRect();
const scaleX = canvasRef.value.width / rect.width;
const scaleY = canvasRef.value.height / rect.height;
if ('touches' in e) {
const touch = e.touches[0];
return {
x: (touch!.clientX - rect.left) * scaleX,
y: (touch!.clientY - rect.top) * scaleY,
};
}
return {
x: (e.clientX - rect.left) * scaleX,
y: (e.clientY - rect.top) * scaleY,
};
};
// 开始绘制
const startDrawing = (e: MouseEvent | TouchEvent) => {
if (props.disabled || !ctx) return;
e.preventDefault();
isDrawing.value = true;
const { x, y } = getCoordinates(e);
lastX = x;
lastY = y;
// 开始新路径
ctx.beginPath();
ctx.moveTo(x, y);
};
// 绘制中
const draw = (e: MouseEvent | TouchEvent) => {
if (!isDrawing.value || props.disabled || !ctx) return;
e.preventDefault();
const { x, y } = getCoordinates(e);
ctx.lineTo(x, y);
ctx.stroke();
lastX = x;
lastY = y;
hasSignature.value = true;
};
// 结束绘制
const stopDrawing = () => {
if (!isDrawing.value) return;
isDrawing.value = false;
emitValue();
};
// 发送值
const emitValue = () => {
if (!canvasRef.value) return;
const dataUrl = canvasRef.value.toDataURL('image/png');
emit('update:modelValue', dataUrl);
emit('change', dataUrl);
};
// 清空画布
const clear = () => {
if (!ctx || !canvasRef.value) return;
ctx.fillStyle = props.backgroundColor;
ctx.fillRect(0, 0, props.width, props.height);
hasSignature.value = false;
emit('update:modelValue', '');
emit('change', '');
};
// 撤销(简单实现:清空)
const undo = () => {
clear();
};
// 获取签名数据
const getSignatureData = () => {
if (!canvasRef.value || !hasSignature.value) return '';
return canvasRef.value.toDataURL('image/png');
};
// 检查是否为空
const isEmpty = () => !hasSignature.value;
// 监听 modelValue 变化
watch(
() => props.modelValue,
(newVal) => {
if (newVal && canvasRef.value) {
loadImage(newVal);
} else if (!newVal) {
clear();
}
},
);
// 暴露方法
defineExpose({
clear,
getSignatureData,
isEmpty,
});
onMounted(() => {
initCanvas();
// 添加全局事件监听
window.addEventListener('mouseup', stopDrawing);
window.addEventListener('touchend', stopDrawing);
});
onUnmounted(() => {
window.removeEventListener('mouseup', stopDrawing);
window.removeEventListener('touchend', stopDrawing);
});
</script>
<template>
<div class="signature-pad">
<div
class="canvas-container"
:class="{ disabled }"
:style="{ width: `${width}px`, height: `${height}px` }"
>
<canvas
ref="canvasRef"
:width="width"
:height="height"
@mousedown="startDrawing"
@mousemove="draw"
@mouseup="stopDrawing"
@mouseleave="stopDrawing"
@touchstart.prevent="startDrawing"
@touchmove.prevent="draw"
@touchend.prevent="stopDrawing"
></canvas>
<!-- 提示文字 -->
<div v-if="!hasSignature && !disabled" class="placeholder">
{{ $t('contract-design.signHere') }}
</div>
</div>
<!-- 操作按钮 -->
<div v-if="!disabled" class="actions mt-2 flex justify-end gap-2">
<ElButton size="small" @click="undo">
<ElIcon class="mr-1"><RotateCcw /></ElIcon>
{{ $t('contract-design.undo') }}
</ElButton>
<ElButton size="small" @click="clear">
<ElIcon class="mr-1"><Eraser /></ElIcon>
{{ $t('contract-design.clear') }}
</ElButton>
</div>
</div>
</template>
<style scoped>
.signature-pad {
display: inline-block;
}
.canvas-container {
position: relative;
overflow: hidden;
touch-action: none; /* 禁用默认触摸行为,确保签名流畅 */
cursor: crosshair;
border: 1px solid var(--el-border-color);
border-radius: 4px;
}
.canvas-container.disabled {
cursor: not-allowed;
opacity: 0.6;
}
.canvas-container canvas {
display: block;
}
.placeholder {
position: absolute;
top: 50%;
left: 50%;
font-size: 14px;
color: var(--el-text-color-placeholder);
pointer-events: none;
user-select: none;
transform: translate(-50%, -50%);
}
</style>
@@ -1,343 +0,0 @@
<script setup lang="ts">
import type { VariableConfig } from '../store/contractDesignStore';
import { computed, ref, watch } from 'vue';
import { AlertCircle, Check, Edit3 } from '@vben/icons';
import { $t } from '@vben/locales';
import {
ElCard,
ElDatePicker,
ElForm,
ElFormItem,
ElIcon,
ElInput,
ElInputNumber,
ElOption,
ElSelect,
ElTag,
} from 'element-plus';
const props = defineProps<{
disabled?: boolean;
modelValue: Record<string, string>;
variables: VariableConfig[];
}>();
const emit = defineEmits<{
(e: 'update:modelValue', value: Record<string, string>): void;
(e: 'change', code: string, value: string): void;
}>();
// 本地表单数据
const formData = ref<Record<string, any>>({});
// 初始化表单数据
watch(
() => props.modelValue,
(val) => {
formData.value = { ...val };
},
{ immediate: true, deep: true },
);
// 更新值
const updateValue = (code: string, value: any) => {
let strValue = '';
if (value === null || value === undefined) {
strValue = '';
} else if (value instanceof Date) {
strValue = formatDate(value);
} else {
strValue = String(value);
}
formData.value[code] = strValue;
const newData = { ...formData.value };
emit('update:modelValue', newData);
emit('change', code, strValue);
};
// 格式化日期
const formatDate = (date: Date, format?: string) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
if (format === 'YYYY-MM-DD') {
return `${year}-${month}-${day}`;
} else if (format === 'YYYY/MM/DD') {
return `${year}/${month}/${day}`;
}
return `${year}${month}${day}`;
};
// 格式化金额
const formatMoney = (value: number) => {
return new Intl.NumberFormat('zh-CN', {
style: 'currency',
currency: 'CNY',
}).format(value);
};
// 按分类分组变量
const groupedVariables = computed(() => {
const groups: Record<string, VariableConfig[]> = {
party_a: [],
party_b: [],
contract: [],
other: [],
};
props.variables.forEach((v) => {
if (v.code.startsWith('party_a_')) {
groups.party_a.push(v);
} else if (v.code.startsWith('party_b_')) {
groups.party_b.push(v);
} else if (
v.code.startsWith('contract_') ||
v.code.includes('date') ||
v.code.includes('location')
) {
groups.contract.push(v);
} else {
groups.other.push(v);
}
});
return groups;
});
// 分组标签
const getGroupLabel = (group: string) => {
const labels: Record<string, string> = {
party_a: $t('contract-design.partyAInfo'),
party_b: $t('contract-design.partyBInfo'),
contract: $t('contract-design.contractInfo'),
other: $t('contract-design.otherInfo'),
};
return labels[group] || group;
};
// 变量类型标签
const typeLabels: Record<string, string> = {
text: '文本',
number: '数字',
date: '日期',
money: '金额',
select: '选择',
};
// 检查变量是否已填写
const isFilled = (code: string) => {
const value = formData.value[code];
return value !== undefined && value !== null && value !== '';
};
// 获取填写进度
const fillProgress = computed(() => {
const required = props.variables.filter((v) => v.required);
if (required.length === 0) return 100;
const filled = required.filter((v) => isFilled(v.code));
return Math.round((filled.length / required.length) * 100);
});
// 未填写的必填变量
const unfilledRequired = computed(() => {
return props.variables.filter((v) => v.required && !isFilled(v.code));
});
</script>
<template>
<div class="variable-form">
<!-- 填写进度 -->
<div
class="mb-4 flex items-center justify-between rounded bg-[var(--el-fill-color-light)] p-3"
>
<div class="flex items-center gap-2">
<ElIcon :size="16" class="text-[var(--el-color-primary)]">
<Edit3 />
</ElIcon>
<span class="text-sm">变量填写进度</span>
</div>
<div class="flex items-center gap-2">
<span
class="text-sm font-medium"
:class="{
'text-[var(--el-color-success)]': fillProgress === 100,
'text-[var(--el-color-warning)]': fillProgress < 100,
}"
>
{{ fillProgress }}%
</span>
<ElIcon
v-if="fillProgress === 100"
:size="16"
class="text-[var(--el-color-success)]"
>
<Check />
</ElIcon>
</div>
</div>
<!-- 未填写提示 -->
<div
v-if="unfilledRequired.length > 0"
class="mb-4 flex items-start gap-2 rounded border border-[var(--el-color-warning-light-5)] bg-[var(--el-color-warning-light-9)] p-3"
>
<ElIcon :size="16" class="mt-0.5 text-[var(--el-color-warning)]">
<AlertCircle />
</ElIcon>
<div class="flex-1">
<div class="mb-1 text-sm font-medium text-[var(--el-color-warning)]">
以下必填项尚未填写
</div>
<div class="flex flex-wrap gap-1">
<ElTag
v-for="v in unfilledRequired"
:key="v.code"
size="small"
type="warning"
>
{{ v.name }}
</ElTag>
</div>
</div>
</div>
<!-- 分组表单 -->
<template v-for="(groupVars, groupKey) in groupedVariables" :key="groupKey">
<ElCard v-if="groupVars.length > 0" shadow="never" class="mb-4">
<template #header>
<span class="text-sm font-medium">{{ groupLabels[groupKey] }}</span>
</template>
<ElForm label-position="top" size="default">
<div class="grid grid-cols-2 gap-x-4">
<ElFormItem
v-for="variable in groupVars"
:key="variable.code"
:label="variable.name"
:required="variable.required"
>
<!-- 文本输入 -->
<ElInput
v-if="variable.type === 'text'"
v-model="formData[variable.code]"
:placeholder="`请输入${variable.name}`"
:disabled="disabled"
clearable
@change="updateValue(variable.code, formData[variable.code])"
/>
<!-- 数字输入 -->
<ElInputNumber
v-else-if="variable.type === 'number'"
v-model="formData[variable.code]"
:placeholder="`请输入${variable.name}`"
:disabled="disabled"
:controls="false"
class="w-full"
@change="updateValue(variable.code, formData[variable.code])"
/>
<!-- 金额输入 -->
<ElInputNumber
v-else-if="variable.type === 'money'"
v-model="formData[variable.code]"
:placeholder="`请输入${variable.name}`"
:disabled="disabled"
:precision="2"
:controls="false"
class="w-full"
@change="updateValue(variable.code, formData[variable.code])"
>
<template #prefix>¥</template>
</ElInputNumber>
<!-- 日期选择 -->
<ElDatePicker
v-else-if="variable.type === 'date'"
v-model="formData[variable.code]"
type="date"
:placeholder="`请选择${variable.name}`"
:disabled="disabled"
:value-format="
variable.format === 'YYYY-MM-DD'
? 'YYYY-MM-DD'
: variable.format === 'YYYY/MM/DD'
? 'YYYY/MM/DD'
: 'YYYY年MM月DD日'
"
class="w-full"
@change="updateValue(variable.code, formData[variable.code])"
/>
<!-- 选择框 -->
<ElSelect
v-else-if="variable.type === 'select'"
v-model="formData[variable.code]"
:placeholder="`请选择${variable.name}`"
:disabled="disabled"
clearable
class="w-full"
@change="updateValue(variable.code, formData[variable.code])"
>
<ElOption
v-for="opt in variable.options"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</ElSelect>
<!-- 类型标签 -->
<template #label>
<div class="flex items-center gap-2">
<span>{{ variable.name }}</span>
<ElTag size="small" type="info">
{{ typeLabels[variable.type] }}
</ElTag>
<ElIcon
v-if="isFilled(variable.code)"
:size="14"
class="text-[var(--el-color-success)]"
>
<Check />
</ElIcon>
</div>
</template>
</ElFormItem>
</div>
</ElForm>
</ElCard>
</template>
<!-- 空状态 -->
<div
v-if="variables.length === 0"
class="flex h-32 items-center justify-center text-[var(--el-text-color-placeholder)]"
>
暂无需要填写的变量
</div>
</div>
</template>
<style scoped>
:deep(.el-form-item__label) {
font-weight: normal;
}
:deep(.el-input-number) {
width: 100%;
}
:deep(.el-input-number .el-input__wrapper) {
padding-right: 11px;
padding-left: 11px;
}
</style>
@@ -1,21 +0,0 @@
export { default as ContractRenderer } from './components/ContractRenderer.vue';
export { default as ContractSignDialog } from './components/ContractSignDialog.vue';
export { default as ElementRenderer } from './components/ElementRenderer.vue';
export { default as SignatureDialog } from './components/SignatureDialog.vue';
export { default as SignaturePad } from './components/SignaturePad.vue';
export { default as VariableForm } from './components/VariableForm.vue';
export { default as ContractDesign } from './index.vue';
export { useContractDesignStore } from './store/contractDesignStore';
export type {
ContractElement,
ContractElementType,
ContractMaterial,
ContractTemplateConfig,
PartyType,
SignatureConfig,
TableColumnConfig,
VariableConfig,
VariableType,
} from './store/contractDesignStore';
export { useContractSignStore } from './store/contractSignStore';
export type { SignatureData, VariableData } from './store/contractSignStore';
@@ -1,323 +0,0 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';
import { $t } from '@vben/locales';
import { Code, RotateCw, Upload } from '@vben/icons';
import {
ElButton,
ElDialog,
ElInput,
ElMessage,
ElMessageBox,
} from 'element-plus';
import AttributePanel from './components/AttributePanel.vue';
import DesignCanvas from './components/DesignCanvas.vue';
import MaterialPanel from './components/MaterialPanel.vue';
import PreviewModal from './components/PreviewModal.vue';
import { useContractDesignStore } from './store/contractDesignStore';
const props = defineProps<{
// 初始配置 JSON
initialConfig?: string;
// 只读模式
readonly?: boolean;
}>();
const emit = defineEmits<{
(e: 'save', config: string): void;
}>();
const store = useContractDesignStore();
// 预览弹窗
const previewVisible = ref(false);
// 代码弹窗
const codeVisible = ref(false);
const codeContent = ref('');
// 导入弹窗
const importVisible = ref(false);
const importContent = ref('');
// 初始化配置
if (props.initialConfig) {
store.importConfig(props.initialConfig);
}
// 预览
const handlePreview = () => {
if (store.templateConfig.elements.length === 0) {
ElMessage.warning($t('contract-design.pleaseAddContractElements'));
return;
}
previewVisible.value = true;
};
// 查看代码
const handleViewCode = () => {
codeContent.value = store.exportConfig();
codeVisible.value = true;
};
// 保存
const handleSave = () => {
const config = store.exportConfig();
emit('save', config);
ElMessage.success($t('contract-design.saveSuccess'));
};
// 清空画布
const handleClear = async () => {
if (store.templateConfig.elements.length === 0) {
return;
}
try {
await ElMessageBox.confirm($t('contract-design.clearCanvasConfirm'), $t('contract-design.tip'), {
confirmButtonText: $t('contract-design.confirm'),
cancelButtonText: $t('contract-design.cancel'),
type: 'warning',
});
store.clearCanvas();
ElMessage.success($t('contract-design.cleared'));
} catch {
// 取消操作
}
};
// 导出配置
const handleExport = () => {
const config = store.exportConfig();
const blob = new Blob([config], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `contract-template-${Date.now()}.json`;
a.click();
URL.revokeObjectURL(url);
ElMessage.success($t('contract-design.exportSuccess'));
};
// 打开导入弹窗
const handleOpenImport = () => {
importContent.value = '';
importVisible.value = true;
};
// 确认导入
const handleImport = () => {
if (!importContent.value.trim()) {
ElMessage.warning($t('contract-design.pleaseEnterConfig'));
return;
}
const success = store.importConfig(importContent.value);
if (success) {
importVisible.value = false;
ElMessage.success($t('contract-design.importSuccess'));
} else {
ElMessage.error($t('contract-design.configFormatError'));
}
};
// 复制代码
const handleCopyCode = async () => {
try {
await navigator.clipboard.writeText(codeContent.value);
ElMessage.success($t('contract-design.copiedToClipboard'));
} catch {
ElMessage.error($t('contract-design.copyFailed'));
}
};
// 键盘快捷键处理
const handleKeyDown = (e: KeyboardEvent) => {
// 如果正在输入框中,不处理快捷键
const target = e.target as HTMLElement;
if (
target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.isContentEditable
) {
return;
}
// 如果弹窗打开,不处理快捷键
if (previewVisible.value || codeVisible.value || importVisible.value) {
return;
}
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
const ctrlKey = isMac ? e.metaKey : e.ctrlKey;
// Ctrl+Z 撤销
if (ctrlKey && e.key === 'z' && !e.shiftKey) {
e.preventDefault();
if (store.canUndo) {
store.undo();
ElMessage.success($t('contract-design.undone'));
}
return;
}
// Ctrl+Y 或 Ctrl+Shift+Z 重做
if ((ctrlKey && e.key === 'y') || (ctrlKey && e.shiftKey && e.key === 'z')) {
e.preventDefault();
if (store.canRedo) {
store.redo();
ElMessage.success($t('contract-design.redone'));
}
return;
}
// Ctrl+C 复制
if (ctrlKey && e.key === 'c') {
if (store.activeId) {
e.preventDefault();
store.copyToClipboard(store.activeId);
ElMessage.success($t('contract-design.copied'));
}
return;
}
// Ctrl+V 粘贴
if (ctrlKey && e.key === 'v') {
if (store.hasClipboard) {
e.preventDefault();
store.pasteFromClipboard();
ElMessage.success($t('contract-design.pasted'));
}
return;
}
// Delete 或 Backspace 删除
if (e.key === 'Delete' || e.key === 'Backspace') {
if (store.activeId) {
e.preventDefault();
store.deleteElement(store.activeId);
ElMessage.success($t('contract-design.deleted'));
}
return;
}
// Ctrl+S 保存
if (ctrlKey && e.key === 's') {
e.preventDefault();
handleSave();
return;
}
};
// 注册/注销键盘事件
onMounted(() => {
window.addEventListener('keydown', handleKeyDown);
});
onUnmounted(() => {
window.removeEventListener('keydown', handleKeyDown);
});
</script>
<template>
<div class="contract-design flex h-full w-full overflow-hidden">
<!-- 主体区域 -->
<div class="bg-background-deep flex flex-1 gap-3 overflow-hidden p-3">
<!-- 左侧元素面板 -->
<div class="h-full flex-shrink-0">
<MaterialPanel />
</div>
<!-- 中间设计画布 -->
<div class="relative h-full min-w-0 flex-1 overflow-hidden">
<DesignCanvas
@preview="handlePreview"
@view-code="handleViewCode"
@clear="handleClear"
@import="handleOpenImport"
@export="handleExport"
@save="handleSave"
/>
</div>
<!-- 右侧属性面板 -->
<div class="h-full flex-shrink-0">
<AttributePanel />
</div>
</div>
<!-- 预览弹窗 -->
<PreviewModal v-model:visible="previewVisible" />
<!-- JSON预览弹窗 -->
<ElDialog
v-model="codeVisible"
:title="$t('contract-design.jsonPreview')"
width="800px"
:close-on-click-modal="false"
>
<template #header>
<div class="flex items-center gap-2">
<Code class="h-5 w-5" />
<span>{{ $t('contract-design.jsonPreview') }}</span>
</div>
</template>
<div class="code-container">
<pre
class="bg-background-deep overflow-auto rounded-lg p-4 text-sm"
><code>{{ codeContent }}</code></pre>
</div>
<template #footer>
<ElButton @click="handleCopyCode"> {{ $t('contract-design.copyCode') }} </ElButton>
<ElButton type="primary" @click="codeVisible = false"> {{ $t('contract-design.close') }} </ElButton>
</template>
</ElDialog>
<!-- 导入弹窗 -->
<ElDialog
v-model="importVisible"
:title="$t('contract-design.importConfig')"
width="600px"
:close-on-click-modal="false"
>
<template #header>
<div class="flex items-center gap-2">
<Upload class="h-5 w-5" />
<span>{{ $t('contract-design.importConfig') }}</span>
</div>
</template>
<ElInput
v-model="importContent"
type="textarea"
:rows="15"
:placeholder="$t('contract-design.pasteJsonConfig')"
/>
<template #footer>
<ElButton @click="importVisible = false">{{ $t('contract-design.cancel') }}</ElButton>
<ElButton type="primary" @click="handleImport">
<RotateCw class="mr-1 h-4 w-4" />
{{ $t('contract-design.import') }}
</ElButton>
</template>
</ElDialog>
</div>
</template>
<style scoped>
.contract-design {
height: 100%;
}
.code-container {
max-height: 500px;
overflow: auto;
}
.code-container pre {
margin: 0;
word-break: break-all;
white-space: pre-wrap;
}
</style>
@@ -1,659 +0,0 @@
import { computed, nextTick, ref } from 'vue';
import { defineStore } from 'pinia';
import { v4 as uuidv4 } from 'uuid';
// 合同元素类型
export type ContractElementType =
| 'date-zone' // 日期区
| 'divider' // 分割线
| 'image' // 图片
| 'page-break' // 分页符
| 'paragraph' // 段落
| 'rich-text' // 富文本
| 'seal-zone' // 盖章区
| 'signature-zone' // 签名区
| 'table' // 表格
| 'title' // 标题
| 'variable'; // 变量占位符
// 变量类型
export type VariableType =
| 'date' // 日期
| 'money' // 金额
| 'number' // 数字
| 'select' // 选择
| 'text'; // 文本
// 签署方类型
export type PartyType = 'party_a' | 'party_b' | 'party_c' | 'witness';
// 变量配置
export interface VariableConfig {
code: string; // 变量编码
name: string; // 变量名称
type: VariableType; // 变量类型
required: boolean; // 是否必填
defaultValue?: string; // 默认值
options?: { label: string; value: string }[]; // 选项(select类型)
format?: string; // 格式(日期/金额)
}
// 签名区配置
export interface SignatureConfig {
partyType: PartyType; // 签署方
partyLabel: string; // 签署方标签
showDate: boolean; // 显示签署日期
showSeal: boolean; // 显示印章位置
required: boolean; // 是否必签
}
// 表格列配置
export interface TableColumnConfig {
key: string;
title: string;
width?: number;
align?: 'center' | 'left' | 'right';
}
// 合同元素
export interface ContractElement {
id: string;
type: ContractElementType;
props: Record<string, any>;
// 变量配置
variable?: VariableConfig;
// 签名配置
signature?: SignatureConfig;
// 表格配置
tableColumns?: TableColumnConfig[];
tableData?: Record<string, any>[];
}
// 合同模板配置
export interface ContractTemplateConfig {
id: string;
name: string;
code: string;
category: string;
description: string;
// 页面设置
pageSize: 'A4' | 'A5' | 'Letter';
pageMargin: { bottom: number; left: number; right: number; top: number };
// 元素列表
elements: ContractElement[];
// 变量定义
variables: VariableConfig[];
// 签署方定义
parties: { label: string; required: boolean; type: PartyType }[];
}
// 预定义变量
export const predefinedVariables: VariableConfig[] = [
{ code: 'party_a_name', name: '甲方名称', type: 'text', required: true },
{
code: 'party_a_legal_person',
name: '甲方法定代表人',
type: 'text',
required: false,
},
{ code: 'party_a_address', name: '甲方地址', type: 'text', required: false },
{ code: 'party_a_phone', name: '甲方电话', type: 'text', required: false },
{ code: 'party_b_name', name: '乙方名称', type: 'text', required: true },
{
code: 'party_b_legal_person',
name: '乙方法定代表人',
type: 'text',
required: false,
},
{ code: 'party_b_address', name: '乙方地址', type: 'text', required: false },
{ code: 'party_b_phone', name: '乙方电话', type: 'text', required: false },
{ code: 'contract_no', name: '合同编号', type: 'text', required: true },
{
code: 'contract_amount',
name: '合同金额',
type: 'money',
required: false,
format: '¥#,##0.00',
},
{
code: 'sign_date',
name: '签订日期',
type: 'date',
required: true,
format: 'YYYY年MM月DD日',
},
{
code: 'start_date',
name: '开始日期',
type: 'date',
required: false,
format: 'YYYY年MM月DD日',
},
{
code: 'end_date',
name: '结束日期',
type: 'date',
required: false,
format: 'YYYY年MM月DD日',
},
{ code: 'sign_location', name: '签订地点', type: 'text', required: false },
];
// 合同元素材料定义
export interface ContractMaterial {
type: ContractElementType;
title: string;
icon: string;
category: 'layout' | 'signature' | 'text' | 'variable';
defaultProps: Record<string, any>;
}
// 预定义合同元素材料
export const contractMaterials: ContractMaterial[] = [
// 文本元素
{
type: 'title',
title: '合同标题',
icon: 'Heading1',
category: 'text',
defaultProps: {
content: '合同标题',
level: 1,
align: 'center',
fontSize: 24,
fontWeight: 'bold',
},
},
{
type: 'paragraph',
title: '段落文本',
icon: 'AlignLeft',
category: 'text',
defaultProps: {
content: '请输入段落内容...',
align: 'left',
fontSize: 14,
lineHeight: 1.8,
indent: 2, // 首行缩进(字符数)
},
},
{
type: 'rich-text',
title: '富文本',
icon: 'FileText',
category: 'text',
defaultProps: {
content: '<p>请输入富文本内容...</p>',
minHeight: 100,
padding: 8,
showBorder: false,
borderColor: 'var(--el-border-color)',
backgroundColor: '',
},
},
// 变量元素
{
type: 'variable',
title: '变量占位符',
icon: 'Code',
category: 'variable',
defaultProps: {
variableCode: '',
placeholder: '______',
underline: true,
minWidth: 100,
},
},
// 表格元素
{
type: 'table',
title: '表格',
icon: 'Table',
category: 'text',
defaultProps: {
bordered: true,
headerBgColor: 'var(--el-fill-color-light)',
},
},
// 签署元素
{
type: 'signature-zone',
title: '签名区',
icon: 'Pencil',
category: 'signature',
defaultProps: {
width: 200,
height: 80,
label: '签名',
showBorder: true,
showLabel: true, // 签字后是否显示标签
align: 'right', // 默认靠右对齐
},
},
{
type: 'seal-zone',
title: '盖章区',
icon: 'Circle',
category: 'signature',
defaultProps: {
width: 120,
height: 120,
label: '盖章处',
showBorder: true,
showLabel: true, // 盖章后是否显示标签
align: 'right', // 默认靠右对齐
},
},
{
type: 'date-zone',
title: '日期区',
icon: 'Calendar',
category: 'signature',
defaultProps: {
format: 'YYYY年MM月DD日',
label: '日期',
showUnderline: true,
align: 'right', // 默认靠右对齐
},
},
// 布局元素
{
type: 'divider',
title: '分割线',
icon: 'Minus',
category: 'layout',
defaultProps: {
style: 'solid',
color: 'var(--el-border-color)',
margin: 16,
},
},
{
type: 'page-break',
title: '分页符',
icon: 'FileText',
category: 'layout',
defaultProps: {},
},
{
type: 'image',
title: '图片',
icon: 'Image',
category: 'layout',
defaultProps: {
src: '',
width: 200,
height: 'auto',
align: 'center',
},
},
];
export const useContractDesignStore = defineStore('contract-design', () => {
// 当前选中的元素
const activeId = ref<null | string>(null);
// 合同模板配置
const templateConfig = ref<ContractTemplateConfig>({
id: uuidv4(),
name: '新建合同模板',
code: '',
category: '',
description: '',
pageSize: 'A4',
pageMargin: { top: 60, right: 60, bottom: 60, left: 60 },
elements: [],
variables: [...predefinedVariables],
parties: [
{ type: 'party_a', label: '甲方', required: true },
{ type: 'party_b', label: '乙方', required: true },
],
});
// 历史记录
const history = ref<string[]>([]);
const historyIndex = ref(-1);
const isTimeTravel = ref(false);
// 剪贴板
const clipboard = ref<ContractElement | null>(null);
// 拖拽状态
const isDragging = ref(false);
// 预览模式
const isPreview = ref(false);
// 记录快照
const recordSnapshot = () => {
if (isTimeTravel.value) return;
if (historyIndex.value < history.value.length - 1) {
history.value.splice(historyIndex.value + 1);
}
history.value.push(JSON.stringify(templateConfig.value));
historyIndex.value = history.value.length - 1;
if (history.value.length > 30) {
history.value.shift();
historyIndex.value--;
}
};
const canUndo = computed(() => historyIndex.value > 0);
const canRedo = computed(() => historyIndex.value < history.value.length - 1);
const undo = () => {
if (!canUndo.value) return;
isTimeTravel.value = true;
historyIndex.value--;
const snapshot = history.value[historyIndex.value];
if (snapshot) {
templateConfig.value = JSON.parse(snapshot);
}
nextTick(() => {
isTimeTravel.value = false;
});
};
const redo = () => {
if (!canRedo.value) return;
isTimeTravel.value = true;
historyIndex.value++;
const snapshot = history.value[historyIndex.value];
if (snapshot) {
templateConfig.value = JSON.parse(snapshot);
}
nextTick(() => {
isTimeTravel.value = false;
});
};
// 设置当前选中
const setActive = (id: null | string) => {
activeId.value = id;
};
// 获取当前选中的元素
const activeElement = computed(() => {
if (!activeId.value) return null;
return (
templateConfig.value.elements.find((e) => e.id === activeId.value) || null
);
});
// 添加元素
const addElement = (material: ContractMaterial, index?: number) => {
const id = uuidv4();
const element: ContractElement = {
id,
type: material.type,
props: JSON.parse(JSON.stringify(material.defaultProps)),
};
// 如果是表格,初始化列和数据
if (material.type === 'table') {
element.tableColumns = [
{ key: 'col1', title: '列1', width: 100, align: 'center' },
{ key: 'col2', title: '列2', width: 100, align: 'center' },
{ key: 'col3', title: '列3', width: 100, align: 'center' },
];
element.tableData = [{ col1: '', col2: '', col3: '' }];
}
// 如果是签名区,初始化签名配置
if (material.type === 'signature-zone' || material.type === 'seal-zone') {
element.signature = {
partyType: 'party_a',
partyLabel: '甲方',
showDate: true,
showSeal: material.type === 'seal-zone',
required: true,
};
}
if (typeof index === 'number') {
templateConfig.value.elements.splice(index, 0, element);
} else {
templateConfig.value.elements.push(element);
}
activeId.value = id;
recordSnapshot();
};
// 删除元素
const deleteElement = (id: string) => {
const index = templateConfig.value.elements.findIndex((e) => e.id === id);
if (index !== -1) {
templateConfig.value.elements.splice(index, 1);
if (activeId.value === id) {
activeId.value = null;
}
recordSnapshot();
}
};
// 复制元素
const copyElement = (id: string) => {
const element = templateConfig.value.elements.find((e) => e.id === id);
if (!element) return;
const newId = uuidv4();
const newElement: ContractElement = {
...JSON.parse(JSON.stringify(element)),
id: newId,
};
const index = templateConfig.value.elements.findIndex((e) => e.id === id);
templateConfig.value.elements.splice(index + 1, 0, newElement);
activeId.value = newId;
recordSnapshot();
};
// 复制到剪贴板
const copyToClipboard = (id: string) => {
const element = templateConfig.value.elements.find((e) => e.id === id);
if (element) {
clipboard.value = JSON.parse(JSON.stringify(element));
}
};
// 从剪贴板粘贴
const pasteFromClipboard = () => {
if (!clipboard.value) return false;
const newId = uuidv4();
const newElement: ContractElement = {
...JSON.parse(JSON.stringify(clipboard.value)),
id: newId,
};
templateConfig.value.elements.push(newElement);
activeId.value = newId;
recordSnapshot();
return true;
};
// 检查剪贴板是否有内容
const hasClipboard = computed(() => clipboard.value !== null);
// 更新元素属性
const updateElementProps = (id: string, props: Record<string, any>) => {
const element = templateConfig.value.elements.find((e) => e.id === id);
if (element) {
element.props = { ...element.props, ...props };
// 如果是变量占位符,且选择了变量,自动添加到模板变量列表
if (element.type === 'variable' && props.variableCode) {
const varConfig = predefinedVariables.find(
(v) => v.code === props.variableCode,
);
if (varConfig) {
const exists = templateConfig.value.variables.find(
(v) => v.code === varConfig.code,
);
if (!exists) {
templateConfig.value.variables.push({ ...varConfig });
}
}
}
}
};
// 更新元素
const updateElement = (id: string, updates: Partial<ContractElement>) => {
const element = templateConfig.value.elements.find((e) => e.id === id);
if (element) {
Object.assign(element, updates);
recordSnapshot();
}
};
// 移动元素
const moveElement = (id: string, direction: 'down' | 'up') => {
const elements = templateConfig.value.elements;
const index = elements.findIndex((e) => e.id === id);
if (index === -1) return;
const newIndex = direction === 'up' ? index - 1 : index + 1;
if (newIndex < 0 || newIndex >= elements.length) return;
[elements[index], elements[newIndex]] = [
elements[newIndex]!,
elements[index]!,
];
recordSnapshot();
};
// 设置拖拽状态
const setDragging = (val: boolean) => {
isDragging.value = val;
};
// 设置预览模式
const setPreview = (val: boolean) => {
isPreview.value = val;
};
// 清空画布
const clearCanvas = () => {
templateConfig.value.elements = [];
activeId.value = null;
recordSnapshot();
};
// 添加变量
const addVariable = (variable: VariableConfig) => {
const exists = templateConfig.value.variables.find(
(v) => v.code === variable.code,
);
if (!exists) {
templateConfig.value.variables.push(variable);
recordSnapshot();
}
};
// 删除变量
const removeVariable = (code: string) => {
const index = templateConfig.value.variables.findIndex(
(v) => v.code === code,
);
if (index !== -1) {
templateConfig.value.variables.splice(index, 1);
recordSnapshot();
}
};
// 更新模板基础配置
const updateTemplateConfig = (config: Partial<ContractTemplateConfig>) => {
Object.assign(templateConfig.value, config);
};
// 导出配置
const exportConfig = () => {
return JSON.stringify(templateConfig.value, null, 2);
};
// 导入配置
const importConfig = (json: string) => {
try {
const config = JSON.parse(json);
templateConfig.value = config;
activeId.value = null;
recordSnapshot();
return true;
} catch {
return false;
}
};
// 克隆元素(用于拖拽)
const cloneElement = (material: ContractMaterial): ContractElement => {
const id = uuidv4();
const element: ContractElement = {
id,
type: material.type,
props: JSON.parse(JSON.stringify(material.defaultProps)),
};
if (material.type === 'table') {
element.tableColumns = [
{ key: 'col1', title: '列1', width: 100, align: 'center' },
{ key: 'col2', title: '列2', width: 100, align: 'center' },
{ key: 'col3', title: '列3', width: 100, align: 'center' },
];
element.tableData = [{ col1: '', col2: '', col3: '' }];
}
if (material.type === 'signature-zone' || material.type === 'seal-zone') {
element.signature = {
partyType: 'party_a',
partyLabel: '甲方',
showDate: true,
showSeal: material.type === 'seal-zone',
required: true,
};
}
return element;
};
return {
activeId,
activeElement,
templateConfig,
isDragging,
isPreview,
canUndo,
canRedo,
hasClipboard,
setActive,
addElement,
deleteElement,
copyElement,
copyToClipboard,
pasteFromClipboard,
updateElementProps,
updateElement,
moveElement,
setDragging,
setPreview,
clearCanvas,
addVariable,
removeVariable,
updateTemplateConfig,
exportConfig,
importConfig,
cloneElement,
undo,
redo,
recordSnapshot,
};
});
@@ -1,181 +0,0 @@
import type { ContractTemplateConfig, PartyType } from './contractDesignStore';
import { computed, ref } from 'vue';
import { defineStore } from 'pinia';
// 签署数据
export interface SignatureData {
elementId: string; // 对应的元素 ID
partyType: PartyType; // 签署方类型
partyLabel: string; // 签署方标签
signatureImage: string; // 签名图片(base64
signedAt: string; // 签署时间
signedBy?: string; // 签署人
}
// 变量填写数据
export interface VariableData {
code: string;
value: string;
}
// 合同签署状态
export interface ContractSignState {
contractId: string;
templateConfig: ContractTemplateConfig | null;
signatures: SignatureData[];
variables: VariableData[];
status: 'completed' | 'draft' | 'signing';
startedAt?: string;
completedAt?: string;
}
export const useContractSignStore = defineStore('contract-sign', () => {
// 合同模板配置
const templateConfig = ref<ContractTemplateConfig | null>(null);
// 签署数据
const signatures = ref<SignatureData[]>([]);
// 变量数据
const variables = ref<VariableData[]>([]);
// 签署状态
const status = ref<'completed' | 'draft' | 'signing'>('draft');
// 加载合同模板
const loadTemplate = (config: ContractTemplateConfig) => {
templateConfig.value = JSON.parse(JSON.stringify(config));
signatures.value = [];
variables.value = [];
status.value = 'signing';
};
// 获取元素的签名数据
const getSignature = (elementId: string): SignatureData | undefined => {
return signatures.value.find((s) => s.elementId === elementId);
};
// 添加或更新签名
const setSignature = (data: Omit<SignatureData, 'signedAt'>) => {
const index = signatures.value.findIndex(
(s) => s.elementId === data.elementId,
);
const signatureData: SignatureData = {
...data,
signedAt: new Date().toISOString(),
};
if (index === -1) {
signatures.value.push(signatureData);
} else {
signatures.value[index] = signatureData;
}
};
// 移除签名
const removeSignature = (elementId: string) => {
const index = signatures.value.findIndex((s) => s.elementId === elementId);
if (index !== -1) {
signatures.value.splice(index, 1);
}
};
// 获取变量值
const getVariable = (code: string): string => {
const variable = variables.value.find((v) => v.code === code);
return variable?.value || '';
};
// 设置变量值
const setVariable = (code: string, value: string) => {
const index = variables.value.findIndex((v) => v.code === code);
if (index === -1) {
variables.value.push({ code, value });
} else {
variables.value[index]!.value = value;
}
};
// 获取所有需要签署的元素
const signatureElements = computed(() => {
if (!templateConfig.value) return [];
return templateConfig.value.elements.filter(
(e) => e.type === 'signature-zone' || e.type === 'seal-zone',
);
});
// 获取所有必须签署的元素
const requiredSignatureElements = computed(() => {
return signatureElements.value.filter((e) => e.signature?.required);
});
// 检查是否所有必须签署的元素都已签署
const allRequiredSigned = computed(() => {
return requiredSignatureElements.value.every((e) =>
signatures.value.some((s) => s.elementId === e.id),
);
});
// 获取签署进度
const signProgress = computed(() => {
const total = requiredSignatureElements.value.length;
if (total === 0) return 100;
const signed = requiredSignatureElements.value.filter((e) =>
signatures.value.some((s) => s.elementId === e.id),
).length;
return Math.round((signed / total) * 100);
});
// 完成签署
const completeSign = () => {
if (!allRequiredSigned.value) {
return false;
}
status.value = 'completed';
return true;
};
// 重置
const reset = () => {
templateConfig.value = null;
signatures.value = [];
variables.value = [];
status.value = 'draft';
};
// 导出签署后的合同数据
const exportSignedContract = () => {
return {
template: templateConfig.value,
signatures: signatures.value,
variables: variables.value,
status: status.value,
completedAt:
status.value === 'completed' ? new Date().toISOString() : undefined,
};
};
return {
templateConfig,
signatures,
variables,
status,
signatureElements,
requiredSignatureElements,
allRequiredSigned,
signProgress,
loadTemplate,
getSignature,
setSignature,
removeSignature,
getVariable,
setVariable,
completeSign,
reset,
exportSignedContract,
};
});
@@ -1,350 +0,0 @@
import type { ContractTemplateConfig } from '../store/contractDesignStore';
// 示例合同模板 - 简单采购合同
export const samplePurchaseContract: ContractTemplateConfig = {
id: 'sample-purchase-001',
name: '采购合同模板',
code: 'PURCHASE_CONTRACT',
category: '采购类',
description: '标准采购合同模板,适用于一般商品采购场景',
pageSize: 'A4',
pageMargin: { top: 60, right: 60, bottom: 60, left: 60 },
elements: [
{
id: 'title-1',
type: 'title',
props: {
content: '采购合同',
level: 1,
align: 'center',
fontSize: 24,
fontWeight: 'bold',
},
},
{
id: 'contract-no',
type: 'paragraph',
props: {
content: '合同编号:{{contract_no}}',
align: 'right',
fontSize: 14,
lineHeight: 1.8,
indent: 0,
},
},
{
id: 'divider-1',
type: 'divider',
props: {
style: 'solid',
color: 'var(--el-border-color)',
margin: 16,
},
},
{
id: 'party-info',
type: 'paragraph',
props: {
content:
'甲方(采购方):{{party_a_name}}\n地址:{{party_a_address}}\n联系电话:{{party_a_phone}}\n\n乙方(供应方):{{party_b_name}}\n地址:{{party_b_address}}\n联系电话:{{party_b_phone}}',
align: 'left',
fontSize: 14,
lineHeight: 1.8,
indent: 0,
},
},
{
id: 'divider-2',
type: 'divider',
props: {
style: 'dashed',
color: 'var(--el-border-color)',
margin: 16,
},
},
{
id: 'intro',
type: 'paragraph',
props: {
content:
'甲乙双方经友好协商,就甲方向乙方采购相关产品事宜,达成如下协议:',
align: 'left',
fontSize: 14,
lineHeight: 1.8,
indent: 2,
},
},
{
id: 'article-1',
type: 'title',
props: {
content: '第一条 采购内容',
level: 3,
align: 'left',
fontSize: 16,
fontWeight: 'bold',
},
},
{
id: 'article-1-content',
type: 'paragraph',
props: {
content:
'甲方向乙方采购以下产品(详见附件清单),合同总金额为人民币 {{contract_amount}} 元整。',
align: 'left',
fontSize: 14,
lineHeight: 1.8,
indent: 2,
},
},
{
id: 'article-2',
type: 'title',
props: {
content: '第二条 交付方式',
level: 3,
align: 'left',
fontSize: 16,
fontWeight: 'bold',
},
},
{
id: 'article-2-content',
type: 'paragraph',
props: {
content:
'乙方应于合同签订后30个工作日内完成产品交付。交付地点为甲方指定地址。',
align: 'left',
fontSize: 14,
lineHeight: 1.8,
indent: 2,
},
},
{
id: 'article-3',
type: 'title',
props: {
content: '第三条 付款方式',
level: 3,
align: 'left',
fontSize: 16,
fontWeight: 'bold',
},
},
{
id: 'article-3-content',
type: 'paragraph',
props: {
content: '甲方应于产品验收合格后15个工作日内,向乙方支付全部合同款项。',
align: 'left',
fontSize: 14,
lineHeight: 1.8,
indent: 2,
},
},
{
id: 'article-4',
type: 'title',
props: {
content: '第四条 违约责任',
level: 3,
align: 'left',
fontSize: 16,
fontWeight: 'bold',
},
},
{
id: 'article-4-content',
type: 'paragraph',
props: {
content:
'任何一方违反本合同约定,应向对方支付合同总金额10%的违约金,并赔偿由此造成的全部损失。',
align: 'left',
fontSize: 14,
lineHeight: 1.8,
indent: 2,
},
},
{
id: 'article-5',
type: 'title',
props: {
content: '第五条 争议解决',
level: 3,
align: 'left',
fontSize: 16,
fontWeight: 'bold',
},
},
{
id: 'article-5-content',
type: 'paragraph',
props: {
content:
'本合同在履行过程中发生的争议,由双方协商解决;协商不成的,任何一方均可向合同签订地人民法院提起诉讼。',
align: 'left',
fontSize: 14,
lineHeight: 1.8,
indent: 2,
},
},
{
id: 'article-6',
type: 'title',
props: {
content: '第六条 其他',
level: 3,
align: 'left',
fontSize: 16,
fontWeight: 'bold',
},
},
{
id: 'article-6-content',
type: 'paragraph',
props: {
content:
'本合同一式两份,甲乙双方各执一份,具有同等法律效力。本合同自双方签字盖章之日起生效。',
align: 'left',
fontSize: 14,
lineHeight: 1.8,
indent: 2,
},
},
{
id: 'divider-3',
type: 'divider',
props: {
style: 'solid',
color: 'var(--el-border-color)',
margin: 24,
},
},
{
id: 'sign-section-title',
type: 'paragraph',
props: {
content: '(以下为签署区)',
align: 'center',
fontSize: 12,
lineHeight: 1.5,
indent: 0,
},
},
{
id: 'sign-party-a',
type: 'paragraph',
props: {
content: '甲方(盖章):',
align: 'left',
fontSize: 14,
lineHeight: 1.8,
indent: 0,
},
},
{
id: 'signature-a',
type: 'signature-zone',
props: {
width: 200,
height: 80,
label: '签名',
showBorder: true,
},
signature: {
partyType: 'party_a',
partyLabel: '甲方签名',
showDate: true,
showSeal: false,
required: true,
},
},
{
id: 'date-a',
type: 'date-zone',
props: {
format: 'YYYY年MM月DD日',
label: '签署日期',
showUnderline: true,
},
},
{
id: 'sign-party-b',
type: 'paragraph',
props: {
content: '乙方(盖章):',
align: 'left',
fontSize: 14,
lineHeight: 1.8,
indent: 0,
},
},
{
id: 'signature-b',
type: 'signature-zone',
props: {
width: 200,
height: 80,
label: '签名',
showBorder: true,
},
signature: {
partyType: 'party_b',
partyLabel: '乙方签名',
showDate: true,
showSeal: false,
required: true,
},
},
{
id: 'date-b',
type: 'date-zone',
props: {
format: 'YYYY年MM月DD日',
label: '签署日期',
showUnderline: true,
},
},
],
variables: [
{ code: 'party_a_name', name: '甲方名称', type: 'text', required: true },
{
code: 'party_a_address',
name: '甲方地址',
type: 'text',
required: false,
},
{ code: 'party_a_phone', name: '甲方电话', type: 'text', required: false },
{ code: 'party_b_name', name: '乙方名称', type: 'text', required: true },
{
code: 'party_b_address',
name: '乙方地址',
type: 'text',
required: false,
},
{ code: 'party_b_phone', name: '乙方电话', type: 'text', required: false },
{ code: 'contract_no', name: '合同编号', type: 'text', required: true },
{
code: 'contract_amount',
name: '合同金额',
type: 'money',
required: true,
format: '¥#,##0.00',
},
],
parties: [
{ type: 'party_a', label: '甲方(采购方)', required: true },
{ type: 'party_b', label: '乙方(供应方)', required: true },
],
};
// 导出所有模板
export const contractTemplates = [
{
id: 'sample-purchase',
name: '采购合同',
description: '标准采购合同模板',
icon: 'FileText',
template: samplePurchaseContract,
},
];
@@ -1,283 +0,0 @@
import html2canvas from 'html2canvas';
import { jsPDF } from 'jspdf';
export interface ExportPdfOptions {
filename?: string;
pageSize?: 'A4' | 'A5' | 'Letter';
pageMargin?: { bottom: number; left: number; right: number; top: number };
quality?: number;
}
// 页面尺寸(单位:mm
const PAGE_SIZES_MM = {
A4: { width: 210, height: 297 },
A5: { width: 148, height: 210 },
Letter: { width: 216, height: 279 },
};
// 页面尺寸(单位:px,96dpi)
const PAGE_SIZES_PX = {
A4: { width: 794, height: 1123 },
A5: { width: 559, height: 794 },
Letter: { width: 816, height: 1056 },
};
/**
* 将多个页面元素导出为 PDF
* @param pages 页面元素数组
* @param options 导出选项
*/
export async function exportPagesToPdf(
pages: HTMLElement[],
options: ExportPdfOptions = {},
): Promise<void> {
const {
filename = `contract-${Date.now()}.pdf`,
pageSize = 'A4',
quality = 4, // 提高默认分辨率,3 = 3倍缩放
} = options;
const pageConfig = PAGE_SIZES_MM[pageSize];
// 创建 PDF
const pdf = new jsPDF({
orientation: 'portrait',
unit: 'mm',
format: [pageConfig.width, pageConfig.height],
});
// 逐页渲染
for (const [i, page] of pages.entries()) {
// 导出前隐藏不需要的元素(如已签署徽章)
const hiddenElements: HTMLElement[] = [];
page.querySelectorAll('.signed-badge').forEach((el) => {
const htmlEl = el as HTMLElement;
hiddenElements.push(htmlEl);
htmlEl.style.display = 'none';
});
// 导出前移除签名区域的边框和背景样式
const signedElements: { el: HTMLElement; originalStyle: string }[] = [];
page
.querySelectorAll('.signature-display.signed, .seal-display.signed')
.forEach((el) => {
const htmlEl = el as HTMLElement;
signedElements.push({
el: htmlEl,
originalStyle: htmlEl.getAttribute('style') || '',
});
htmlEl.style.border = 'none';
htmlEl.style.background = 'transparent';
htmlEl.style.padding = '0';
});
// 使用 html2canvas 将元素转换为 canvas
const canvas = await html2canvas(page, {
scale: quality,
useCORS: true,
allowTaint: true,
backgroundColor: '#ffffff',
logging: false,
});
// 导出后恢复隐藏的元素
hiddenElements.forEach((el) => {
el.style.display = '';
});
// 导出后恢复签名区域的样式
signedElements.forEach(({ el, originalStyle }) => {
el.setAttribute('style', originalStyle);
});
// 计算图片尺寸(填满整页)
const imgData = canvas.toDataURL('image/jpeg', 0.95);
if (i > 0) {
pdf.addPage();
}
// 添加图片,填满整页
pdf.addImage(imgData, 'JPEG', 0, 0, pageConfig.width, pageConfig.height);
}
// 下载 PDF
pdf.save(filename);
}
/**
* 将 HTML 元素导出为 PDF
* @param element 要导出的 HTML 元素
* @param options 导出选项
*/
export async function exportToPdf(
element: HTMLElement,
options: ExportPdfOptions = {},
): Promise<void> {
const {
filename = `contract-${Date.now()}.pdf`,
pageSize = 'A4',
pageMargin = { top: 10, right: 10, bottom: 10, left: 10 },
quality = 2,
} = options;
const pageConfig = PAGE_SIZES_MM[pageSize];
// 导出前隐藏不需要的元素(如已签署徽章)
const hiddenElements: HTMLElement[] = [];
element.querySelectorAll('.signed-badge').forEach((el) => {
const htmlEl = el as HTMLElement;
hiddenElements.push(htmlEl);
htmlEl.style.display = 'none';
});
// 导出前移除签名区域的边框和背景样式
const signedElements: { el: HTMLElement; originalStyle: string }[] = [];
element
.querySelectorAll('.signature-display.signed, .seal-display.signed')
.forEach((el) => {
const htmlEl = el as HTMLElement;
signedElements.push({
el: htmlEl,
originalStyle: htmlEl.getAttribute('style') || '',
});
htmlEl.style.border = 'none';
htmlEl.style.background = 'transparent';
htmlEl.style.padding = '0';
});
// 使用 html2canvas 将元素转换为 canvas
const canvas = await html2canvas(element, {
scale: quality,
useCORS: true,
allowTaint: true,
backgroundColor: '#ffffff',
logging: false,
});
// 导出后恢复隐藏的元素
hiddenElements.forEach((el) => {
el.style.display = '';
});
// 导出后恢复签名区域的样式
signedElements.forEach(({ el, originalStyle }) => {
el.setAttribute('style', originalStyle);
});
// 创建 PDF
const pdf = new jsPDF({
orientation: 'portrait',
unit: 'mm',
format: [pageConfig.width, pageConfig.height],
});
// 计算可用区域(减去边距)
const contentWidth = pageConfig.width - pageMargin.left - pageMargin.right;
const contentHeight = pageConfig.height - pageMargin.top - pageMargin.bottom;
// 计算图片尺寸(按宽度缩放)
const imgWidth = contentWidth;
const imgHeight = (canvas.height * imgWidth) / canvas.width;
// 将 canvas 转换为图片数据
const imgData = canvas.toDataURL('image/jpeg', 0.95);
// 如果内容高度小于等于一页可用高度,直接添加
if (imgHeight <= contentHeight) {
pdf.addImage(
imgData,
'JPEG',
pageMargin.left,
pageMargin.top,
imgWidth,
imgHeight,
);
} else {
// 需要分页
let remainingHeight = imgHeight;
let sourceY = 0;
let pageIndex = 0;
while (remainingHeight > 0) {
if (pageIndex > 0) {
pdf.addPage();
}
// 计算当前页显示的内容高度
const currentPageContentHeight = Math.min(remainingHeight, contentHeight);
// 计算源图片中对应的像素高度
const sourceHeight =
(currentPageContentHeight / imgHeight) * canvas.height;
// 创建临时 canvas 来裁剪当前页的内容
const tempCanvas = document.createElement('canvas');
tempCanvas.width = canvas.width;
tempCanvas.height = sourceHeight;
const tempCtx = tempCanvas.getContext('2d');
if (tempCtx) {
tempCtx.drawImage(
canvas,
0,
sourceY,
canvas.width,
sourceHeight, // 源区域
0,
0,
canvas.width,
sourceHeight, // 目标区域
);
const pageImgData = tempCanvas.toDataURL('image/jpeg', 0.95);
pdf.addImage(
pageImgData,
'JPEG',
pageMargin.left,
pageMargin.top,
imgWidth,
currentPageContentHeight,
);
}
sourceY += sourceHeight;
remainingHeight -= contentHeight;
pageIndex++;
}
}
// 下载 PDF
pdf.save(filename);
}
/**
* 将合同渲染器导出为 PDF
* @param containerSelector 容器选择器
* @param options 导出选项
*/
export async function exportContractToPdf(
containerSelector: string,
options: ExportPdfOptions = {},
): Promise<boolean> {
const container = document.querySelector(containerSelector) as HTMLElement;
if (!container) {
console.error('Container not found:', containerSelector);
return false;
}
// 查找合同纸张元素
const paper = container.querySelector('.contract-paper') as HTMLElement;
if (!paper) {
console.error('Contract paper not found');
return false;
}
try {
await exportToPdf(paper, options);
return true;
} catch (error) {
console.error('Export PDF failed:', error);
return false;
}
}
@@ -1,826 +0,0 @@
<script setup lang="ts">
import type {
ImportExportManagerEmits,
ImportExportManagerProps,
TaskRecord,
} from './types';
import type { ImportResult } from '#/api/online-dev/form-data-api';
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { onBeforeRouteLeave } from 'vue-router';
import {
AlertCircle,
ArrowDownToLine,
ArrowUpFromLine,
CheckCircle2,
Download,
FileSpreadsheet,
Loader,
RefreshCw,
Square,
Trash2,
Upload,
XCircle,
} from '@vben/icons';
import { UploadFilled } from '@element-plus/icons-vue';
import { useLocalStorage } from '@vueuse/core';
import {
ElButton,
ElCheckbox,
ElCollapse,
ElCollapseItem,
ElIcon,
ElMessage,
ElPopconfirm,
ElPopover,
ElScrollbar,
ElTabPane,
ElTabs,
ElTag,
ElUpload,
} from 'element-plus';
import {
cancelExportTaskApi,
createExportTaskApi,
downloadFormDataExcel,
downloadImportTemplate,
importFormDataExcelApi,
pollExportTask,
} from '#/api/online-dev/form-data-api';
defineOptions({ name: 'ImportExportManager' });
const props = withDefaults(defineProps<ImportExportManagerProps>(), {
formName: '',
showImport: true,
showExport: true,
showTemplate: true,
});
const emit = defineEmits<ImportExportManagerEmits>();
// 状态
const activeTab = ref('import');
const popoverVisible = ref(false);
const importLoading = ref(false);
const exportLoading = ref(false);
const updateExisting = ref(false);
const includeSubTables = ref(true);
const useAsyncExport = ref(true); // 默认使用异步导出
const exportProgress = ref(0);
// 任务历史
const taskHistory = useLocalStorage<TaskRecord[]>(
`import-export-history-${props.formCode}`,
[],
);
// 最近导入结果
const lastImportResult = ref<ImportResult | null>(null);
const showErrorDetails = ref<string[]>([]);
// 存储活跃的轮询停止函数
const activePollers = new Map<string, () => void>();
// 启动轮询任务
function startPolling(taskId: string, filename: string, isLatest: boolean) {
const { promise, stop } = pollExportTask(props.formCode, taskId, {
filename,
onProgress: (progress, status, details) => {
updateTaskRecord(taskId, {
progress,
totalCount: details?.total_count,
processedCount: details?.processed_count,
status:
status === 'processing'
? 'running'
: (status === 'completed'
? 'success'
: 'failed'),
});
if (isLatest) {
exportProgress.value = progress;
}
},
});
// 存储 stop 函数
activePollers.set(taskId, stop);
promise
.then(() => {
updateTaskRecord(taskId, { status: 'success' });
ElMessage.success(`任务 ${filename} 导出成功`);
})
.catch((error) => {
updateTaskRecord(taskId, {
status: 'failed',
error: error.message,
});
if (isLatest) {
ElMessage.error(`任务 ${filename} 失败: ${error.message}`);
}
})
.finally(() => {
activePollers.delete(taskId);
if (isLatest) {
exportLoading.value = false;
exportProgress.value = 0;
}
});
}
// 恢复未完成的任务
function recoverRunningTasks() {
taskHistory.value.forEach((task) => {
if (task.type === 'export' && task.status === 'running') {
exportLoading.value = true;
useAsyncExport.value = true;
const isLatest = taskHistory.value[0]?.id === task.id;
startPolling(task.id, task.filename, isLatest);
}
});
}
onMounted(() => {
recoverRunningTasks();
});
// 停止所有轮询
function stopAllPollers() {
activePollers.forEach((stop) => stop());
activePollers.clear();
}
// 页面卸载时停止所有轮询
onUnmounted(() => {
stopAllPollers();
});
// 路由离开时停止所有轮询
onBeforeRouteLeave(() => {
stopAllPollers();
});
// 计算属性
const hasErrors = computed(
() => lastImportResult.value && lastImportResult.value.failed > 0,
);
// 生成任务ID
function generateTaskId() {
return `task_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
}
// 添加任务记录
function addTaskRecord(
type: 'export' | 'import',
filename: string,
options?: any,
): TaskRecord {
const record: TaskRecord = {
id: generateTaskId(),
type,
filename,
status: 'running',
startTime: new Date(),
options,
};
taskHistory.value.unshift(record);
return record;
}
// 更新任务记录
function updateTaskRecord(id: string, updates: Partial<TaskRecord>) {
const record = taskHistory.value.find((t) => t.id === id);
if (record) {
Object.assign(record, updates, {
endTime: ['failed', 'success'].includes(updates.status || '')
? new Date()
: undefined,
});
}
}
// 下载模板
async function handleDownloadTemplate() {
try {
await downloadImportTemplate(
props.formCode,
`${props.formName || props.formCode}_导入模板.xlsx`,
);
ElMessage.success('模板下载成功');
} catch {
ElMessage.error('模板下载失败');
}
}
// 导出数据
async function handleExport() {
exportLoading.value = true;
exportProgress.value = 0;
const filename = `${props.formName || props.formCode}_数据.xlsx`;
const options = {
includeSubTables: includeSubTables.value,
filename,
};
const record = addTaskRecord('export', filename, options);
try {
if (useAsyncExport.value) {
// 异步导出(适用于大数据量)
// 1. 创建后端任务
const { task_id } = await createExportTaskApi(props.formCode, {
includeSubTables: includeSubTables.value,
});
// 2. 更新前端记录 ID 为后端 UUID
const index = taskHistory.value.findIndex((t) => t.id === record.id);
if (index !== -1 && taskHistory.value[index]) {
taskHistory.value[index]!.id = task_id;
record.id = task_id;
}
// 3. 跳转到进行中 Tab
activeTab.value = 'running';
// 4. 启动轮询(会自动处理成功/失败/停止)
startPolling(task_id, filename, true);
return; // 轮询会处理后续逻辑
} else {
// 同步导出(适用于小数据量)
await downloadFormDataExcel(props.formCode, {
includeSubTables: includeSubTables.value,
filename,
});
}
updateTaskRecord(record.id, {
status: 'success',
result: { count: 0 },
});
ElMessage.success('导出成功');
emit('export-success');
} catch (error: any) {
updateTaskRecord(record.id, {
status: 'failed',
error: error?.message || '导出失败',
});
ElMessage.error(error?.message || '导出失败');
} finally {
exportLoading.value = false;
exportProgress.value = 0;
}
}
// 上传导入文件
async function handleUploadRequest(options: any) {
const { file } = options;
importLoading.value = true;
lastImportResult.value = null;
showErrorDetails.value = [];
const record = addTaskRecord('import', file.name);
try {
const result = await importFormDataExcelApi(
props.formCode,
file,
updateExisting.value,
);
lastImportResult.value = result;
updateTaskRecord(record.id, {
status: result.failed > 0 ? 'failed' : 'success',
result,
});
if (result.failed > 0) {
ElMessage.warning(
`导入完成:成功 ${result.success} 条,失败 ${result.failed}`,
);
showErrorDetails.value = ['errors'];
} else {
ElMessage.success(`成功导入 ${result.success} 条数据`);
emit('import-success', result);
}
} catch (error: any) {
updateTaskRecord(record.id, {
status: 'failed',
error: error?.message || '导入失败',
});
ElMessage.error(error?.message || '导入失败');
} finally {
importLoading.value = false;
}
}
// 重试任务
function handleRetry(task: TaskRecord) {
if (task.type === 'export' && task.options) {
activeTab.value = 'export';
includeSubTables.value = task.options.includeSubTables ?? true;
useAsyncExport.value = true;
handleExport();
}
}
// 取消任务
async function handleCancelTask(task: TaskRecord) {
if (task.type !== 'export' || task.status !== 'running') {
return;
}
try {
// 1. 停止前端轮询
const stopFn = activePollers.get(task.id);
if (stopFn) {
stopFn();
activePollers.delete(task.id);
}
// 2. 调用后端取消 API
await cancelExportTaskApi(props.formCode, task.id);
// 3. 更新任务状态为已取消
updateTaskRecord(task.id, { status: 'cancelled' });
// 4. 重置导出状态
exportLoading.value = false;
exportProgress.value = 0;
ElMessage.info('导出任务已取消');
} catch (error: any) {
ElMessage.error(error?.message || '取消失败');
}
}
// 清除历史
function handleClearHistory() {
taskHistory.value = runningTasks.value;
ElMessage.success('历史记录已清除');
}
// 计算属性
const runningTasks = computed(() =>
taskHistory.value.filter(
(t) => t.status === 'running' || t.status === 'pending',
),
);
const completedTasks = computed(() =>
taskHistory.value.filter(
(t) => t.status !== 'running' && t.status !== 'pending',
),
);
// 判断是否过期
function isExpired(task: TaskRecord) {
if (task.status === 'success' && task.expiresAt) {
return new Date(task.expiresAt) < new Date();
}
return false;
}
// 格式化时间
function formatTime(date: Date | string) {
const d = new Date(date);
return d.toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
}
// 获取状态标签类型
function getStatusType(
status: string,
): 'danger' | 'info' | 'success' | 'warning' {
const map: Record<string, 'danger' | 'info' | 'success' | 'warning'> = {
pending: 'info',
running: 'warning',
success: 'success',
failed: 'danger',
cancelled: 'info',
};
return map[status] || 'info';
}
// 获取状态文本
function getStatusText(status: string) {
const map: Record<string, string> = {
pending: '等待中',
running: '进行中',
success: '成功',
failed: '失败',
cancelled: '已取消',
};
return map[status] || status;
}
</script>
<template>
<ElPopover
v-model:visible="popoverVisible"
placement="bottom-end"
:width="420"
trigger="click"
>
<template #reference>
<ElButton circle :icon="FileSpreadsheet" title="导入导出" />
</template>
<div class="import-export-manager">
<ElTabs v-model="activeTab" class="!-mt-2">
<!-- 导入 Tab -->
<ElTabPane v-if="showImport" label="导入" name="import">
<div class="flex flex-col gap-4">
<!-- 下载模板 -->
<div
v-if="showTemplate"
class="flex items-center justify-between rounded-lg bg-[var(--el-fill-color-lighter)] p-3"
>
<div class="flex items-center gap-2">
<ArrowDownToLine
class="h-4 w-4 text-[var(--el-color-primary)]"
/>
<span class="text-sm">下载导入模板</span>
</div>
<ElButton
size="small"
type="primary"
link
@click="handleDownloadTemplate"
>
下载
</ElButton>
</div>
<!-- 上传区域 -->
<ElUpload
drag
action=""
:http-request="handleUploadRequest"
:show-file-list="false"
:disabled="importLoading"
accept=".xlsx,.xls"
class="!w-full"
>
<div
v-loading="importLoading"
element-loading-text="正在导入..."
class="flex flex-col items-center py-4"
>
<ElIcon
class="mb-2 text-3xl text-[var(--el-text-color-secondary)]"
>
<UploadFilled />
</ElIcon>
<div class="text-sm text-[var(--el-text-color-secondary)]">
拖拽文件到此处
<em class="not-italic text-[var(--el-color-primary)]">点击上传</em>
</div>
<div
class="mt-1 text-xs text-[var(--el-text-color-placeholder)]"
>
支持 .xlsx, .xls 格式
</div>
</div>
</ElUpload>
<!-- 选项 -->
<div class="flex items-center">
<ElCheckbox v-model="updateExisting" size="small">
更新已存在的数据根据ID匹配
</ElCheckbox>
</div>
<!-- 导入结果 -->
<div
v-if="lastImportResult"
class="rounded-lg border border-[var(--el-border-color-lighter)] p-3"
>
<div class="mb-2 flex items-center justify-between">
<span class="text-sm font-medium">导入结果</span>
<div class="flex items-center gap-3 text-sm">
<span
class="flex items-center gap-1 text-[var(--el-color-success)]"
>
<CheckCircle2 class="h-4 w-4" />
{{ lastImportResult.success }}
</span>
<span
v-if="lastImportResult.failed > 0"
class="flex items-center gap-1 text-[var(--el-color-danger)]"
>
<XCircle class="h-4 w-4" />
{{ lastImportResult.failed }}
</span>
</div>
</div>
<!-- 错误详情 -->
<ElCollapse v-if="hasErrors" v-model="showErrorDetails">
<ElCollapseItem title="查看错误详情" name="errors">
<ElScrollbar max-height="150px">
<div class="space-y-1">
<div
v-for="(err, idx) in lastImportResult.errors"
:key="idx"
class="flex items-start gap-2 rounded bg-[var(--el-color-danger-light-9)] p-2 text-xs"
>
<AlertCircle
class="mt-0.5 h-3 w-3 flex-shrink-0 text-[var(--el-color-danger)]"
/>
<div>
<span class="font-medium"> {{ err.row }} </span>
<span v-if="err.field">字段 {{ err.field }}</span>
<span>{{ err.error }}</span>
</div>
</div>
</div>
</ElScrollbar>
</ElCollapseItem>
</ElCollapse>
</div>
</div>
</ElTabPane>
<!-- 导出 Tab -->
<ElTabPane v-if="showExport" label="导出" name="export">
<div class="flex flex-col gap-4">
<div class="rounded-lg bg-[var(--el-fill-color-lighter)] p-4">
<div class="mb-3 flex items-center gap-2">
<Download class="h-4 w-4 text-[var(--el-color-primary)]" />
<span class="text-sm font-medium">导出数据</span>
</div>
<div class="mb-3 space-y-2">
<ElCheckbox v-model="includeSubTables" size="small">
包含子表数据多Sheet
</ElCheckbox>
<ElCheckbox v-model="useAsyncExport" size="small">
异步导出推荐大数据量使用
</ElCheckbox>
</div>
<!-- 导出进度条 -->
<div
v-if="
exportLoading && useAsyncExport && runningTasks.length > 0
"
class="mb-3"
>
<div class="mb-1 flex justify-between text-xs">
<span>导出进度</span>
<span class="flex items-center gap-2">
<span>{{ runningTasks[0]?.progress || 0 }}%</span>
<span
v-if="runningTasks[0]?.totalCount"
class="text-[var(--el-text-color-secondary)]"
>
({{ runningTasks[0]?.processedCount || 0 }}/{{
runningTasks[0]?.totalCount
}}
条)
</span>
</span>
</div>
<div
class="h-2 overflow-hidden rounded-full bg-[var(--el-fill-color)]"
>
<div
class="h-full rounded-full bg-[var(--el-color-primary)] transition-all duration-300"
:style="{ width: `${runningTasks[0]?.progress || 0}%` }"
></div>
</div>
</div>
<ElButton
type="primary"
:loading="exportLoading"
:disabled="runningTasks.length > 0"
class="w-full"
@click="handleExport"
>
<template #icon>
<ArrowUpFromLine class="h-4 w-4" />
</template>
{{
exportLoading || runningTasks.length > 0
? '导出中...'
: '导出 Excel'
}}
</ElButton>
</div>
</div>
</ElTabPane>
<!-- 进行中 Tab -->
<ElTabPane label="进行中" name="running">
<template #label>
<span class="flex items-center gap-1">
进行中
<ElTag
v-if="runningTasks.length > 0"
type="primary"
size="small"
round
>
{{ runningTasks.length }}
</ElTag>
</span>
</template>
<div
v-if="runningTasks.length === 0"
class="py-8 text-center text-sm text-[var(--el-text-color-placeholder)]"
>
暂无进行中的任务
</div>
<ElScrollbar v-else max-height="250px">
<div class="space-y-2">
<div
v-for="task in runningTasks"
:key="task.id"
class="flex items-center justify-between rounded-lg border border-[var(--el-border-color-lighter)] p-3"
>
<div class="flex items-center gap-3">
<div
class="flex h-8 w-8 items-center justify-center rounded-full bg-[var(--el-color-primary-light-9)]"
>
<Loader
class="h-4 w-4 animate-spin text-[var(--el-color-primary)]"
/>
</div>
<div>
<div class="text-sm font-medium">{{ task.filename }}</div>
<div
class="text-xs text-[var(--el-text-color-placeholder)]"
>
开始于 {{ formatTime(task.startTime) }}
</div>
</div>
</div>
<div class="flex items-center gap-3">
<div class="flex flex-col items-end gap-1">
<div class="flex items-center gap-2 text-xs">
<span class="text-[var(--el-color-primary)]">
{{ task.progress || 0 }}%
</span>
<span
v-if="task.totalCount"
class="text-[var(--el-text-color-secondary)]"
>
{{ task.processedCount || 0 }}/{{ task.totalCount }}
</span>
</div>
<div
class="h-1.5 w-24 overflow-hidden rounded-full bg-[var(--el-fill-color)]"
>
<div
class="h-full rounded-full bg-[var(--el-color-primary)] transition-all duration-300"
:style="{ width: `${task.progress || 0}%` }"
></div>
</div>
</div>
<ElPopconfirm
title="确定要取消此导出任务吗?"
confirm-button-text="确定"
cancel-button-text="取消"
@confirm="handleCancelTask(task)"
>
<template #reference>
<ElButton
type="danger"
size="small"
circle
title="取消导出"
>
<Square class="h-3 w-3" />
</ElButton>
</template>
</ElPopconfirm>
</div>
</div>
</div>
</ElScrollbar>
</ElTabPane>
<!-- 历史记录 Tab -->
<ElTabPane label="历史" name="history">
<div class="mb-2 flex justify-end">
<ElButton
v-if="completedTasks.length > 0"
type="danger"
link
size="small"
@click="handleClearHistory"
>
<template #icon>
<Trash2 class="h-3 w-3" />
</template>
清除历史
</ElButton>
</div>
<div
v-if="completedTasks.length === 0"
class="py-8 text-center text-sm text-[var(--el-text-color-placeholder)]"
>
暂无历史记录
</div>
<ElScrollbar v-else max-height="250px">
<div class="space-y-2">
<div
v-for="task in completedTasks"
:key="task.id"
class="flex items-center justify-between rounded-lg border border-[var(--el-border-color-lighter)] p-3"
>
<div class="flex items-center gap-3">
<div
class="flex h-8 w-8 items-center justify-center rounded-full"
:class="
task.type === 'import'
? 'bg-[var(--el-color-success-light-9)]'
: 'bg-[var(--el-color-primary-light-9)]'
"
>
<Upload
v-if="task.type === 'import'"
class="h-4 w-4 text-[var(--el-color-success)]"
/>
<Download
v-else
class="h-4 w-4 text-[var(--el-color-primary)]"
/>
</div>
<div>
<div class="flex items-center gap-2">
<div class="text-sm font-medium">{{ task.filename }}</div>
<ElTag
v-if="isExpired(task)"
type="info"
size="small"
effect="plain"
>
已过期
</ElTag>
</div>
<div
class="text-xs text-[var(--el-text-color-placeholder)]"
>
{{ formatTime(task.endTime || task.startTime) }}
</div>
</div>
</div>
<div class="flex items-center gap-2">
<ElButton
v-if="task.status === 'failed' && task.type === 'export'"
type="primary"
link
size="small"
@click="handleRetry(task)"
>
<template #icon>
<RefreshCw class="h-3 w-3" />
</template>
重试
</ElButton>
<ElTag :type="getStatusType(task.status)" size="small">
{{ getStatusText(task.status) }}
</ElTag>
</div>
</div>
</div>
</ElScrollbar>
</ElTabPane>
</ElTabs>
</div>
</ElPopover>
</template>
<style scoped>
.import-export-manager :deep(.el-tabs__header) {
margin-bottom: 12px;
}
.import-export-manager :deep(.el-upload-dragger) {
padding: 0;
border-color: var(--el-border-color);
}
.import-export-manager :deep(.el-upload-dragger:hover) {
border-color: var(--el-color-primary);
}
.import-export-manager :deep(.el-collapse-item__header) {
height: 32px;
font-size: 12px;
line-height: 32px;
color: var(--el-color-danger);
}
.import-export-manager :deep(.el-collapse-item__content) {
padding-bottom: 0;
}
</style>
@@ -1,2 +0,0 @@
export { default as ImportExportManager } from './import-export-manager.vue';
export * from './types';
@@ -1,67 +0,0 @@
/** 导入结果 */
export interface ImportResult {
success: number;
failed: number;
errors: ImportError[];
warnings?: string[];
}
/** 导入错误详情 */
export interface ImportError {
row: number;
field: null | string;
error: string;
type: 'header' | 'option' | 'required' | 'system' | 'type';
}
/** 导出选项 */
export interface ExportOptions {
ids?: string[];
includeSubTables?: boolean;
filename?: string;
}
/** 任务状态 */
export type TaskStatus =
| 'cancelled'
| 'failed'
| 'pending'
| 'running'
| 'success';
/** 任务记录 */
export interface TaskRecord {
id: string;
type: 'export' | 'import';
filename: string;
status: TaskStatus;
startTime: Date;
endTime?: Date;
progress?: number;
totalCount?: number; // 总条数
processedCount?: number; // 已处理条数
result?: ImportResult | { count: number };
error?: string;
expiresAt?: string; // 任务过期时间
options?: ExportOptions; // 导出选项,用于重试
}
/** 组件 Props */
export interface ImportExportManagerProps {
/** 表单编码 */
formCode: string;
/** 表单名称 */
formName?: string;
/** 是否显示导入功能 */
showImport?: boolean;
/** 是否显示导出功能 */
showExport?: boolean;
/** 是否显示下载模板 */
showTemplate?: boolean;
}
/** 组件 Emits */
export interface ImportExportManagerEmits {
(e: 'import-success', result: ImportResult): void;
(e: 'export-success'): void;
}
@@ -1,141 +0,0 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useI18n } from '@vben/locales'
import {
ElForm, ElFormItem, ElInput, ElSelect, ElOption, ElMessage,
} from 'element-plus'
import { ZqDialog } from '#/components/zq-dialog'
import { useWikiStore } from '#/store/wiki'
import ImageSelector from '#/components/zq-form/image-selector/image-selector.vue'
const props = defineProps<{ modelValue: boolean }>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
created: [spaceId: string]
}>()
const { t } = useI18n()
const wikiStore = useWikiStore()
const visible = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v),
})
const saving = ref(false)
const form = ref({
name: '',
description: '',
category: 'default',
visibility: 'private',
avatar: undefined as string | undefined,
})
const categories = computed(() => [
{ value: 'default', label: t('wiki.categoryDefault') },
{ value: 'tech', label: t('wiki.categoryTech') },
{ value: 'product', label: t('wiki.categoryProduct') },
{ value: 'design', label: t('wiki.categoryDesign') },
{ value: 'business', label: t('wiki.categoryBusiness') },
{ value: 'other', label: t('wiki.categoryOther') },
])
const visibilityOptions = computed(() => [
{ value: 'private', label: t('wiki.private') },
{ value: 'team', label: t('wiki.team') },
{ value: 'public', label: t('wiki.public') },
])
watch(() => props.modelValue, (v) => {
if (v) {
form.value = { name: '', description: '', category: 'default', visibility: 'private', avatar: undefined }
}
})
async function handleCreate() {
const name = form.value.name.trim()
if (!name) {
ElMessage.warning(t('wiki.spaceNamePlaceholder'))
return
}
saving.value = true
try {
const id = await wikiStore.createSpace({
name,
description: form.value.description.trim() || undefined,
category: form.value.category,
visibility: form.value.visibility,
avatar: form.value.avatar || undefined,
})
if (id) {
visible.value = false
emit('created', id)
}
} catch {
ElMessage.error('Failed')
} finally {
saving.value = false
}
}
</script>
<template>
<ZqDialog
v-model="visible"
:title="t('wiki.createSpace')"
width="480px"
:confirm-loading="saving"
@confirm="handleCreate"
>
<ElForm label-position="top">
<ElFormItem :label="t('wiki.avatar')">
<ImageSelector
v-model="form.avatar"
:size="72"
enable-crop
:crop-aspect-ratio="1"
crop-shape="circle"
source="wiki"
/>
</ElFormItem>
<ElFormItem :label="t('wiki.spaceName')" required>
<ElInput
v-model="form.name"
:placeholder="t('wiki.spaceNamePlaceholder')"
maxlength="200"
show-word-limit
@keyup.enter="handleCreate"
/>
</ElFormItem>
<ElFormItem :label="t('wiki.spaceDescription')">
<ElInput
v-model="form.description"
type="textarea"
:rows="3"
:placeholder="t('wiki.spaceDescriptionPlaceholder')"
maxlength="500"
/>
</ElFormItem>
<ElFormItem :label="t('wiki.category')">
<ElSelect v-model="form.category" class="w-full">
<ElOption
v-for="c in categories"
:key="c.value"
:label="c.label"
:value="c.value"
/>
</ElSelect>
</ElFormItem>
<ElFormItem :label="t('wiki.visibility')">
<ElSelect v-model="form.visibility" class="w-full">
<ElOption
v-for="v in visibilityOptions"
:key="v.value"
:label="v.label"
:value="v.value"
/>
</ElSelect>
</ElFormItem>
</ElForm>
</ZqDialog>
</template>
@@ -1,151 +0,0 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useI18n } from '@vben/locales'
import {
ElForm, ElFormItem, ElInput, ElSelect, ElOption, ElMessage,
} from 'element-plus'
import { ZqDialog } from '#/components/zq-dialog'
import { useWikiStore } from '#/store/wiki'
import type { WikiSpace } from '#/types/zq-smart-table/table'
import ImageSelector from '#/components/zq-form/image-selector/image-selector.vue'
const props = defineProps<{
modelValue: boolean
space: WikiSpace | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
updated: [spaceId: string]
}>()
const { t } = useI18n()
const wikiStore = useWikiStore()
const visible = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v),
})
const saving = ref(false)
const form = ref({
name: '',
description: '',
category: 'default',
visibility: 'private',
avatar: undefined as string | undefined,
})
const categories = computed(() => [
{ value: 'default', label: t('wiki.categoryDefault') },
{ value: 'tech', label: t('wiki.categoryTech') },
{ value: 'product', label: t('wiki.categoryProduct') },
{ value: 'design', label: t('wiki.categoryDesign') },
{ value: 'business', label: t('wiki.categoryBusiness') },
{ value: 'other', label: t('wiki.categoryOther') },
])
const visibilityOptions = computed(() => [
{ value: 'private', label: t('wiki.private') },
{ value: 'team', label: t('wiki.team') },
{ value: 'public', label: t('wiki.public') },
])
watch(() => props.modelValue, (v) => {
if (v && props.space) {
form.value = {
name: props.space.name,
description: props.space.description || '',
category: props.space.category || 'default',
visibility: props.space.visibility || 'private',
avatar: props.space.avatar || undefined,
}
}
})
async function handleSave() {
if (!props.space) return
const name = form.value.name.trim()
if (!name) {
ElMessage.warning(t('wiki.spaceNamePlaceholder'))
return
}
saving.value = true
try {
await wikiStore.updateSpace(props.space.id, {
name,
description: form.value.description.trim() || null,
category: form.value.category,
visibility: form.value.visibility,
avatar: form.value.avatar || null,
})
ElMessage.success(t('wiki.editSpaceSuccess'))
visible.value = false
emit('updated', props.space.id)
} catch {
ElMessage.error('Failed')
} finally {
saving.value = false
}
}
</script>
<template>
<ZqDialog
v-model="visible"
:title="t('wiki.editSpace')"
width="480px"
:confirm-loading="saving"
@confirm="handleSave"
>
<ElForm label-position="top">
<ElFormItem :label="t('wiki.avatar')">
<ImageSelector
v-model="form.avatar"
:size="72"
enable-crop
:crop-aspect-ratio="1"
crop-shape="circle"
source="wiki"
/>
</ElFormItem>
<ElFormItem :label="t('wiki.spaceName')" required>
<ElInput
v-model="form.name"
:placeholder="t('wiki.spaceNamePlaceholder')"
maxlength="200"
show-word-limit
@keyup.enter="handleSave"
/>
</ElFormItem>
<ElFormItem :label="t('wiki.spaceDescription')">
<ElInput
v-model="form.description"
type="textarea"
:rows="3"
:placeholder="t('wiki.spaceDescriptionPlaceholder')"
maxlength="500"
/>
</ElFormItem>
<ElFormItem :label="t('wiki.category')">
<ElSelect v-model="form.category" class="w-full">
<ElOption
v-for="c in categories"
:key="c.value"
:label="c.label"
:value="c.value"
/>
</ElSelect>
</ElFormItem>
<ElFormItem :label="t('wiki.visibility')">
<ElSelect v-model="form.visibility" class="w-full">
<ElOption
v-for="v in visibilityOptions"
:key="v.value"
:label="v.label"
:value="v.value"
/>
</ElSelect>
</ElFormItem>
</ElForm>
</ZqDialog>
</template>
@@ -1,903 +0,0 @@
<script setup lang="ts">
import { ref, computed, nextTick, watch } from 'vue'
import { useTableStore } from '#/store/zq-smart-table'
import { SmartItemType, type Table } from '#/types/zq-smart-table/table'
import { useI18n } from '@vben/locales'
import { useRouter, useRoute } from 'vue-router'
import {
Grid,
FileText,
Search,
ChevronDown,
ChevronRight,
Ellipsis,
Pencil,
Trash2,
FilePlus2,
BookOpen,
} from '@vben/icons'
import { ElMessageBox } from 'element-plus'
type TreeNode = Table & { children: TreeNode[] }
interface FlatNode {
id: string
name: string
type: SmartItemType
depth: number
hasChildren: boolean
parentId: string | null | undefined
}
const emit = defineEmits<{
selectSpace: [spaceId: string]
selectDoc: [tableId: string]
showSpaces: []
}>()
const { t } = useI18n()
const tableStore = useTableStore()
const router = useRouter()
const route = useRoute()
const SIDEBAR_DEFAULT = 256
const SIDEBAR_MIN = 200
const SIDEBAR_MAX = 480
const sidebarWidth = ref(SIDEBAR_DEFAULT)
const isResizing = ref(false)
const searchText = ref('')
function onResizeStart(e: MouseEvent) {
e.preventDefault()
isResizing.value = true
const startX = e.clientX
const startW = sidebarWidth.value
function onMove(ev: MouseEvent) {
const newW = startW + (ev.clientX - startX)
sidebarWidth.value = Math.max(SIDEBAR_MIN, Math.min(SIDEBAR_MAX, newW))
}
function onUp() {
isResizing.value = false
document.removeEventListener('mousemove', onMove)
document.removeEventListener('mouseup', onUp)
}
document.addEventListener('mousemove', onMove)
document.addEventListener('mouseup', onUp)
}
function onResizeDblClick() {
sidebarWidth.value = SIDEBAR_DEFAULT
}
// Active state
const activeView = computed(() => {
const tableId = route.params.tableId as string | undefined
if (tableId) return { type: 'doc' as const, id: tableId }
return { type: 'spaces' as const, id: null }
})
// ==================== My Documents Tree ====================
const expandedIds = ref<Set<string>>(new Set())
function toggleExpand(id: string) {
const s = new Set(expandedIds.value)
if (s.has(id)) s.delete(id)
else s.add(id)
expandedIds.value = s
}
function buildTree(items: Table[]): TreeNode[] {
const map = new Map<string, TreeNode>()
const roots: TreeNode[] = []
for (const item of items) {
map.set(item.id, { ...item, children: [] })
}
for (const node of map.values()) {
const pid = node.parentId
if (pid && map.has(pid)) {
map.get(pid)!.children.push(node)
} else {
roots.push(node)
}
}
return roots
}
function filterTree(nodes: TreeNode[], q: string): TreeNode[] {
if (!q) return nodes
const result: TreeNode[] = []
for (const node of nodes) {
const childMatch = filterTree(node.children, q)
if (node.name.toLowerCase().includes(q) || childMatch.length > 0) {
result.push({ ...node, children: childMatch })
}
}
return result
}
function flattenTree(nodes: TreeNode[], depth = 0): FlatNode[] {
const result: FlatNode[] = []
for (const node of nodes) {
result.push({
id: node.id,
name: node.name,
type: node.type,
depth,
hasChildren: node.children.length > 0,
parentId: node.parentId,
})
if (node.children.length > 0 && expandedIds.value.has(node.id)) {
result.push(...flattenTree(node.children, depth + 1))
}
}
return result
}
const tree = computed(() => buildTree(tableStore.tables))
const filteredTree = computed(() => {
const q = searchText.value.trim().toLowerCase()
return filterTree(tree.value, q)
})
const flatList = computed(() => flattenTree(filteredTree.value))
const hasSearchResults = computed(() => flatList.value.length > 0)
watch(() => tableStore.tables, () => {
if (expandedIds.value.size === 0 && tableStore.tables.length > 0) {
const ids = new Set<string>()
function walk(nodes: TreeNode[]) {
for (const n of nodes) {
if (n.children.length > 0) {
ids.add(n.id)
walk(n.children)
}
}
}
walk(tree.value as TreeNode[])
expandedIds.value = ids
}
}, { immediate: true })
// Drag state
const dragId = ref<string | null>(null)
const dropTargetId = ref<string | null>(null)
const dropPosition = ref<'inside' | 'before' | 'after' | null>(null)
function onDragStart(e: DragEvent, id: string) {
dragId.value = id
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', id)
}
}
function onDragOver(e: DragEvent, id: string) {
e.preventDefault()
if (dragId.value === id) return
dropTargetId.value = id
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
const y = e.clientY - rect.top
const h = rect.height
if (y < h * 0.25) dropPosition.value = 'before'
else if (y > h * 0.75) dropPosition.value = 'after'
else dropPosition.value = 'inside'
}
function onDragLeave() {
dropTargetId.value = null
dropPosition.value = null
}
function onDrop(e: DragEvent) {
e.preventDefault()
if (!dragId.value || !dropTargetId.value || dragId.value === dropTargetId.value) {
resetDrag()
return
}
const targetItem = tableStore.tables.find(t => t.id === dropTargetId.value)
if (!targetItem) { resetDrag(); return }
const draggedId = dragId.value
if (dropPosition.value === 'inside') {
const children = tableStore.tables.filter(
(t) => (t.parentId ?? null) === dropTargetId.value && t.id !== draggedId,
)
const lastChild = children.length > 0 ? children[children.length - 1] : null
tableStore.moveTable(draggedId, dropTargetId.value, lastChild?.id ?? null)
expandedIds.value = new Set([...expandedIds.value, dropTargetId.value])
} else {
const newParentId = targetItem.parentId ?? null
const siblings = tableStore.tables.filter(
(t) => (t.parentId ?? null) === newParentId && t.id !== draggedId,
)
const targetIdx = siblings.findIndex((s) => s.id === dropTargetId.value)
let afterId: string | null = null
if (dropPosition.value === 'before') {
afterId = targetIdx > 0 ? (siblings[targetIdx - 1]?.id ?? null) : null
} else {
afterId = dropTargetId.value
}
tableStore.moveTable(draggedId, newParentId, afterId)
}
resetDrag()
}
function resetDrag() {
dragId.value = null
dropTargetId.value = null
dropPosition.value = null
}
function getItemDropClass(nodeId: string) {
if (dropTargetId.value !== nodeId) return ''
if (dropPosition.value === 'inside') return 'drop-inside'
if (dropPosition.value === 'before') return 'drop-before'
if (dropPosition.value === 'after') return 'drop-after'
return ''
}
// Rename
const renamingId = ref<string | null>(null)
const renameValue = ref('')
const renameInputEl = ref<HTMLInputElement | null>(null)
function setRenameRef(el: any) { renameInputEl.value = el }
function startRename(tableId: string, currentName: string) {
renamingId.value = tableId
renameValue.value = currentName
nextTick(() => {
renameInputEl.value?.focus()
renameInputEl.value?.select()
})
}
async function confirmRename(tableId: string) {
if (renameValue.value.trim()) {
const table = tableStore.tables.find((item) => item.id === tableId)
if (table) {
table.name = renameValue.value.trim()
const { updateTableApi } = await import('#/api/smart-table')
updateTableApi(tableId, { name: table.name }).catch(() => {})
}
}
renamingId.value = null
}
function handleDeleteTable(tableId: string, tableName: string) {
ElMessageBox.confirm(
t('sidebar.deleteTableConfirm', { name: tableName }),
t('sidebar.deleteTable'),
{
confirmButtonText: t('common.confirm'),
cancelButtonText: t('common.cancel'),
type: 'warning',
},
).then(async () => {
await tableStore.deleteTable(tableId)
if (activeView.value.type === 'doc' && activeView.value.id === tableId) {
router.push('/wiki')
}
}).catch(() => {})
}
function handleContextCommand(command: string, tableId: string, tableName: string) {
if (command === 'rename') startRename(tableId, tableName)
else if (command === 'delete') handleDeleteTable(tableId, tableName)
else if (command === 'addSubPage') handleAddSubPage(tableId)
}
// Actions
async function handleAddDocument() {
const name = t('document.untitled')
const newId = await tableStore.addDocument(name)
if (newId) {
await tableStore.loadTableFull(newId)
router.push(`/wiki/doc/${newId}`)
emit('selectDoc', newId)
}
}
async function handleAddSubPage(parentId: string) {
const name = t('document.untitled')
const newId = await tableStore.addSubPage(parentId, name)
if (newId) {
expandedIds.value = new Set([...expandedIds.value, parentId])
await tableStore.loadTableFull(newId)
router.push(`/wiki/doc/${newId}`)
emit('selectDoc', newId)
}
}
function handleSelectDoc(tableId: string) {
if (renamingId.value === tableId) return
tableStore.activeTableId = tableId
router.push(`/wiki/doc/${tableId}`)
emit('selectDoc', tableId)
}
function handleShowSpaces() {
router.push('/wiki')
emit('showSpaces')
}
function getNodeIcon(type: SmartItemType) {
return type === SmartItemType.Document ? FileText : Grid
}
</script>
<template>
<div
class="zq-sidebar"
:class="{ 'is-resizing': isResizing }"
:style="{ width: `${sidebarWidth}px` }"
>
<div class="zq-sidebar__inner" :style="{ width: `${sidebarWidth}px` }">
<!-- Header -->
<div class="zq-sidebar__header">
<div class="zq-sidebar__brand">
<div class="zq-sidebar__logo">
<BookOpen class="w-4 h-4" />
</div>
<span class="zq-sidebar__brand-title">{{ t('wiki.title') }}</span>
</div>
</div>
<!-- Search -->
<div class="zq-sidebar__search">
<Search class="zq-sidebar__search-icon" />
<input
v-model="searchText"
class="zq-sidebar__search-input"
:placeholder="t('wiki.searchDocPlaceholder')"
/>
</div>
<!-- Scrollable content -->
<div class="zq-sidebar__content">
<!-- Wiki Spaces entry -->
<div class="zq-sidebar__section">
<div
class="zq-sidebar__section-header"
:class="{ 'is-active': activeView.type === 'spaces' }"
@click="handleShowSpaces"
>
<BookOpen class="zq-sidebar__section-icon" />
<span class="zq-sidebar__section-title">{{ t('wiki.wikiSpaces') }}</span>
</div>
</div>
<!-- Divider -->
<div class="zq-sidebar__divider" />
<!-- My Documents Section -->
<div class="zq-sidebar__section">
<div class="zq-sidebar__section-header zq-sidebar__section-header--docs">
<FileText class="zq-sidebar__section-icon" />
<span class="zq-sidebar__section-title">{{ t('wiki.myDocuments') }}</span>
</div>
<!-- Document tree -->
<template v-if="flatList.length > 0">
<div
v-for="node in flatList"
:key="node.id"
class="zq-sidebar__item"
:style="{ '--depth': node.depth }"
:class="[
{
'is-active': activeView.type === 'doc' && activeView.id === node.id,
'is-dragging': dragId === node.id,
},
getItemDropClass(node.id),
]"
draggable="true"
@dragstart="onDragStart($event, node.id)"
@dragover="onDragOver($event, node.id)"
@dragleave="onDragLeave"
@drop="onDrop($event)"
@dragend="resetDrag"
@click="handleSelectDoc(node.id)"
>
<button
v-if="node.hasChildren"
class="zq-sidebar__expand-toggle"
@click.stop="toggleExpand(node.id)"
>
<component
:is="expandedIds.has(node.id) ? ChevronDown : ChevronRight"
class="w-3.5 h-3.5"
/>
</button>
<span v-else class="zq-sidebar__expand-spacer" />
<component :is="getNodeIcon(node.type)" class="zq-sidebar__item-icon" />
<input
v-if="renamingId === node.id"
:ref="setRenameRef"
v-model="renameValue"
class="zq-sidebar__rename-input"
@blur="confirmRename(node.id)"
@keyup.enter="confirmRename(node.id)"
@keyup.escape="renamingId = null"
@click.stop
/>
<span v-else class="zq-sidebar__item-name">{{ node.name }}</span>
<el-dropdown
v-if="renamingId !== node.id"
trigger="click"
class="zq-sidebar__item-actions"
@command="(cmd: string) => handleContextCommand(cmd, node.id, node.name)"
>
<button class="zq-sidebar__more-btn" @click.stop>
<Ellipsis class="w-3.5 h-3.5" />
</button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="addSubPage">
<div class="flex items-center gap-2">
<FilePlus2 class="w-3.5 h-3.5" />
<span>{{ t('sidebar.newSubPage') }}</span>
</div>
</el-dropdown-item>
<el-dropdown-item command="rename">
<div class="flex items-center gap-2">
<Pencil class="w-3.5 h-3.5" />
<span>{{ t('common.rename') }}</span>
</div>
</el-dropdown-item>
<el-dropdown-item command="delete" divided>
<div class="flex items-center gap-2" style="color: var(--zq-danger-color)">
<Trash2 class="w-3.5 h-3.5" />
<span>{{ t('common.delete') }}</span>
</div>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</template>
<!-- Empty my docs -->
<div v-if="!searchText && flatList.length === 0 && !tableStore.loading" class="zq-sidebar__empty">
{{ t('wiki.emptyMyDocs') }}
</div>
<!-- Search no results -->
<div v-if="searchText && !hasSearchResults" class="zq-sidebar__no-results">
<Search class="w-6 h-6" style="color: var(--zq-text-placeholder)" />
<span>{{ t('wiki.noResults') }}</span>
</div>
</div>
</div>
<!-- Bottom actions -->
<div class="zq-sidebar__footer">
<button class="zq-sidebar__footer-btn" @click="handleAddDocument">
<FilePlus2 class="w-4 h-4" />
<span>{{ t('sidebar.newDocument') }}</span>
</button>
</div>
</div>
<!-- Resize handle -->
<div
class="zq-sidebar__resize-handle"
@mousedown="onResizeStart"
@dblclick="onResizeDblClick"
/>
</div>
</template>
<style scoped>
.zq-sidebar {
position: relative;
flex-shrink: 0;
transition: width 0.25s cubic-bezier(0.4, 0, 0.2, 1);
overflow: visible;
z-index: 20;
}
.zq-sidebar.is-resizing {
transition: none;
}
.zq-sidebar__inner {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--zq-bg-sidebar);
border-right: 1px solid var(--zq-border-color-light);
}
/* Resize handle */
.zq-sidebar__resize-handle {
position: absolute;
top: 0;
right: -3px;
width: 3px;
height: 100%;
cursor: col-resize;
z-index: 30;
transition: background-color 0.15s;
}
.zq-sidebar__resize-handle:hover,
.zq-sidebar.is-resizing .zq-sidebar__resize-handle {
background-color: var(--zq-brand-color);
}
/* Header */
.zq-sidebar__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 12px 8px;
flex-shrink: 0;
}
.zq-sidebar__brand {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.zq-sidebar__logo {
width: 30px;
height: 30px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
background: var(--zq-brand-color);
flex-shrink: 0;
}
.zq-sidebar__brand-title {
font-size: 14px;
font-weight: 600;
color: var(--zq-text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Search */
.zq-sidebar__search {
display: flex;
align-items: center;
gap: 8px;
margin: 4px 12px 8px;
padding: 7px 10px;
border-radius: 8px;
background: var(--zq-bg-primary);
border: 1px solid transparent;
transition: all 0.15s;
}
.zq-sidebar__search:focus-within {
border-color: var(--zq-brand-color);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--zq-brand-color) 15%, transparent);
}
.zq-sidebar__search-icon {
width: 14px;
height: 14px;
color: var(--zq-text-placeholder);
flex-shrink: 0;
}
.zq-sidebar__search-input {
flex: 1;
min-width: 0;
border: none;
outline: none;
background: transparent;
font-size: 13px;
color: var(--zq-text-primary);
line-height: 1;
}
.zq-sidebar__search-input::placeholder {
color: var(--zq-text-placeholder);
}
/* Scrollable content */
.zq-sidebar__content {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 4px 0 16px;
}
.zq-sidebar__content::-webkit-scrollbar {
width: 4px;
}
.zq-sidebar__content::-webkit-scrollbar-thumb {
background: var(--zq-border-color);
border-radius: 4px;
}
.zq-sidebar__content::-webkit-scrollbar-thumb:hover {
background: var(--zq-text-placeholder);
}
/* Section */
.zq-sidebar__section {
padding: 0 8px;
}
.zq-sidebar__section-header {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
margin: 2px 0;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
font-weight: 500;
color: var(--zq-text-secondary);
transition: all 0.15s;
}
.zq-sidebar__section-header:hover {
background: var(--zq-bg-cell-hover);
color: var(--zq-text-primary);
}
.zq-sidebar__section-header.is-active {
background: var(--zq-brand-color-light);
color: var(--zq-brand-color);
}
.zq-sidebar__section-header--docs {
cursor: default;
}
.zq-sidebar__section-header--docs:hover {
background: transparent;
color: var(--zq-text-secondary);
}
.zq-sidebar__section-icon {
width: 15px;
height: 15px;
flex-shrink: 0;
}
.zq-sidebar__section-title {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.zq-sidebar__section-add {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 4px;
border: none;
background: transparent;
color: var(--zq-text-placeholder);
cursor: pointer;
transition: all 0.15s;
flex-shrink: 0;
}
.zq-sidebar__section-add:hover {
background: var(--zq-bg-cell-hover);
color: var(--zq-brand-color);
}
/* Divider */
.zq-sidebar__divider {
height: 1px;
margin: 6px 12px;
background: var(--zq-border-color-light);
}
/* Tree items (same as ZqTableSidebar) */
.zq-sidebar__item {
display: flex;
align-items: center;
gap: 4px;
padding: 5px 8px 5px calc(8px + var(--depth, 0) * 16px);
margin: 1px 0;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
color: var(--zq-text-primary);
transition: background-color 0.15s, color 0.15s;
position: relative;
user-select: none;
}
.zq-sidebar__item:hover {
background: var(--zq-bg-cell-hover);
}
.zq-sidebar__item.is-active {
background: var(--zq-brand-color-light);
color: var(--zq-brand-color);
}
.zq-sidebar__item.is-active::before {
content: '';
position: absolute;
left: 0;
top: 6px;
bottom: 6px;
width: 3px;
border-radius: 0 2px 2px 0;
background: var(--zq-brand-color);
}
.zq-sidebar__item.is-dragging {
opacity: 0.4;
}
.zq-sidebar__item.drop-inside {
background: color-mix(in srgb, var(--zq-brand-color) 12%, transparent);
outline: 2px solid var(--zq-brand-color);
outline-offset: -2px;
border-radius: 6px;
}
.zq-sidebar__item.drop-before {
box-shadow: inset 0 2px 0 0 var(--zq-brand-color);
}
.zq-sidebar__item.drop-after {
box-shadow: inset 0 -2px 0 0 var(--zq-brand-color);
}
.zq-sidebar__expand-toggle {
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border-radius: 4px;
border: none;
background: transparent;
color: var(--zq-text-placeholder);
cursor: pointer;
flex-shrink: 0;
transition: all 0.15s;
}
.zq-sidebar__expand-toggle:hover {
background: var(--zq-bg-cell-hover);
color: var(--zq-text-secondary);
}
.zq-sidebar__expand-spacer {
width: 18px;
flex-shrink: 0;
}
.zq-sidebar__item-icon {
width: 15px;
height: 15px;
flex-shrink: 0;
opacity: 0.7;
}
.zq-sidebar__item.is-active .zq-sidebar__item-icon {
opacity: 1;
}
.zq-sidebar__item-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.4;
}
.zq-sidebar__rename-input {
flex: 1;
min-width: 0;
font-size: 13px;
line-height: 1.4;
padding: 1px 6px;
border-radius: 4px;
border: 1px solid var(--zq-brand-color);
outline: none;
background: var(--zq-bg-primary);
color: var(--zq-text-primary);
}
.zq-sidebar__item-actions {
flex-shrink: 0;
margin-left: auto;
}
.zq-sidebar__more-btn {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 4px;
border: none;
background: transparent;
color: var(--zq-text-placeholder);
cursor: pointer;
opacity: 0;
transition: all 0.15s;
flex-shrink: 0;
}
.zq-sidebar__item:hover .zq-sidebar__more-btn {
opacity: 1;
}
.zq-sidebar__more-btn:hover {
background: var(--zq-bg-secondary);
color: var(--zq-text-secondary);
}
/* Empty & No Results */
.zq-sidebar__empty {
padding: 16px;
font-size: 12px;
color: var(--zq-text-placeholder);
text-align: center;
}
.zq-sidebar__no-results {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 24px 16px;
font-size: 13px;
color: var(--zq-text-placeholder);
}
/* Footer */
.zq-sidebar__footer {
flex-shrink: 0;
border-top: 1px solid var(--zq-border-color-light);
padding: 8px;
display: flex;
flex-direction: column;
gap: 2px;
}
.zq-sidebar__footer-btn {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 7px 12px;
border-radius: 6px;
border: none;
background: transparent;
color: var(--zq-text-secondary);
font-size: 13px;
cursor: pointer;
transition: all 0.15s;
}
.zq-sidebar__footer-btn:hover {
background: var(--zq-bg-cell-hover);
color: var(--zq-text-primary);
}
</style>
@@ -1,256 +0,0 @@
<script lang="ts" setup>
import type { InstanceDocument } from '#/api/online-dev/workflow';
import { computed, ref, watch } from 'vue';
import { Download, RefreshCw } from '@vben/icons';
import { $t } from '@vben/locales';
import { ElButton, ElEmpty, ElMessage } from 'element-plus';
import {
generateInstanceDocumentsApi,
getInstanceDocumentsApi,
} from '#/api/online-dev/workflow';
import { ZqDialog } from '#/components/zq-dialog';
import { ZqTabs } from '#/components/zq-tabs/index';
import { getFileUrl } from '#/composables/useFileUrl';
interface Props {
instanceId: string;
}
const props = defineProps<Props>();
const visible = defineModel<boolean>('visible', { default: false });
const documentsLoading = ref(false);
const documentsList = ref<InstanceDocument[]>([]);
const generating = ref(false);
const activeDocId = ref('');
const previewUrls = ref<Record<string, string>>({});
const previewLoading = ref(false);
const currentPreviewDoc = computed(
() =>
documentsList.value.find((doc) => doc.id === activeDocId.value) ||
documentsList.value[0] ||
null,
);
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
async function loadPreviewUrl(doc: InstanceDocument) {
if (previewUrls.value[doc.id]) return;
previewLoading.value = true;
try {
const url = await getFileUrl(doc.file_id);
const response = await fetch(url);
if (!response.ok) {
throw new Error(`${$t('common.loadFailed')}: ${response.status}`);
}
const blob = await response.blob();
previewUrls.value[doc.id] = URL.createObjectURL(blob);
} catch (error: any) {
ElMessage.error(error?.message || $t('common.loadFailed'));
} finally {
previewLoading.value = false;
}
}
async function loadDocuments() {
if (!props.instanceId) return;
documentsLoading.value = true;
documentsList.value = [];
activeDocId.value = '';
Object.values(previewUrls.value).forEach((url) => URL.revokeObjectURL(url));
previewUrls.value = {};
try {
let docs = await getInstanceDocumentsApi(props.instanceId);
if (docs.length === 0) {
generating.value = true;
try {
await generateInstanceDocumentsApi(props.instanceId);
docs = await getInstanceDocumentsApi(props.instanceId);
} catch (genError: any) {
ElMessage.error(
genError?.message || $t('workflow.generateDocumentsFailed'),
);
} finally {
generating.value = false;
}
}
documentsList.value = docs;
if (docs.length > 0) {
activeDocId.value = docs[0].id;
await loadPreviewUrl(docs[0]);
}
} catch (error: any) {
ElMessage.error(error?.message || $t('common.loadFailed'));
} finally {
documentsLoading.value = false;
}
}
async function handleTabChange(docId: string) {
activeDocId.value = docId;
const doc = documentsList.value.find((d) => d.id === docId);
if (doc) {
await loadPreviewUrl(doc);
}
}
async function handleDownloadDocument(doc: InstanceDocument) {
try {
const cachedUrl = previewUrls.value[doc.id];
let downloadUrl: string;
let needRevoke = false;
if (cachedUrl) {
downloadUrl = cachedUrl;
} else {
const url = await getFileUrl(doc.file_id);
const response = await fetch(url);
if (!response.ok) {
throw new Error(`${$t('common.downloadFailed')}: ${response.status}`);
}
const blob = await response.blob();
downloadUrl = window.URL.createObjectURL(blob);
needRevoke = true;
}
const link = document.createElement('a');
link.href = downloadUrl;
link.download = `${doc.document_name}.pdf`;
document.body.append(link);
link.click();
link.remove();
if (needRevoke) {
window.URL.revokeObjectURL(downloadUrl);
}
} catch (error: any) {
ElMessage.error(error?.message || $t('common.downloadFailed'));
}
}
async function handleRegenerateDocuments() {
if (!props.instanceId) return;
generating.value = true;
Object.values(previewUrls.value).forEach((url) => URL.revokeObjectURL(url));
previewUrls.value = {};
try {
await generateInstanceDocumentsApi(props.instanceId);
const docs = await getInstanceDocumentsApi(props.instanceId);
documentsList.value = docs;
if (docs.length > 0) {
activeDocId.value = docs[0].id;
await loadPreviewUrl(docs[0]);
}
ElMessage.success($t('workflow.regenerateDocumentsSuccess'));
} catch (error: any) {
ElMessage.error(error?.message || $t('workflow.regenerateDocumentsFailed'));
} finally {
generating.value = false;
}
}
function handleDialogClosed() {
activeDocId.value = '';
Object.values(previewUrls.value).forEach((url) => URL.revokeObjectURL(url));
previewUrls.value = {};
}
watch(visible, (val) => {
if (val && props.instanceId) {
loadDocuments();
}
});
</script>
<template>
<ZqDialog
v-model="visible"
:title="$t('workflow.documents')"
width="1000px"
:show-footer="false"
@closed="handleDialogClosed"
>
<div
v-loading="documentsLoading || generating"
class="bg-background min-h-[calc(100vh-160px)]"
>
<template
v-if="!documentsLoading && !generating && documentsList.length === 0"
>
<ElEmpty :description="$t('workflow.noDocuments')" />
</template>
<template v-else-if="documentsList.length > 0">
<template v-if="documentsList.length > 1">
<ZqTabs
:items="
documentsList.map((doc) => ({
key: doc.id,
label: doc.document_name,
}))
"
:model-value="activeDocId"
@update:model-value="handleTabChange"
/>
</template>
<div
v-if="currentPreviewDoc"
class="mb-3 flex items-center justify-between"
>
<div class="text-muted-foreground text-sm">
{{ currentPreviewDoc.template_name }} ·
{{ formatFileSize(currentPreviewDoc.file_size) }} ·
{{ currentPreviewDoc.page_count }} {{ $t('workflow.pages') }}
</div>
<div class="flex gap-2">
<ElButton
size="small"
:loading="generating"
@click="handleRegenerateDocuments"
>
<RefreshCw class="mr-1 h-4 w-4" />
{{ $t('workflow.regenerateDocuments') }}
</ElButton>
<ElButton
type="primary"
size="small"
@click="handleDownloadDocument(currentPreviewDoc)"
>
<Download class="mr-1 h-4 w-4" />
{{ $t('common.download') }}
</ElButton>
</div>
</div>
<div v-loading="previewLoading" class="h-[calc(100vh-200px)]">
<iframe
v-if="currentPreviewDoc && previewUrls[currentPreviewDoc.id]"
:src="previewUrls[currentPreviewDoc.id]"
class="h-full w-full border-none"
></iframe>
</div>
</template>
</div>
</ZqDialog>
</template>
@@ -1,527 +0,0 @@
<script lang="ts" setup>
import type { FlowDefinition, FlowNode } from '../designer/types';
/**
* 流程路径预览组件
* 基于流程定义展示审批路径(用于发起流程时预览)
*/
import { computed, ref, watch } from 'vue';
import {
Circle,
Clock,
Forward,
GitBranch,
PenLine,
Play,
Square,
User,
Users,
} from '@vben/icons';
import { $t } from '@vben/locales';
import { ElEmpty, ElSkeleton, ElTag } from 'element-plus';
import { getDeptUsersApi } from '#/api/core/dept';
import { getRoleUsersApi } from '#/api/core/role';
import { getNodeDisplayName } from '#/components/workflow/designer/utils/node-display';
import { UserAvatar } from '#/components/user-avatar/index';
interface Props {
flowDefinition: FlowDefinition | null;
loading?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
loading: false,
});
// 缓存角色和部门下的用户
const roleUsersCache = ref<Record<string, string[]>>({});
const deptUsersCache = ref<Record<string, string[]>>({});
// 节点类型图标配置
const nodeIconConfig: Record<string, any> = {
start: Play,
end: Square,
approval: User,
handle: User,
copy: Forward,
condition: GitBranch,
parallel: Users,
delay: Clock,
notify: Forward,
service: Circle,
subflow: GitBranch,
data_update: PenLine,
};
// 需要显示的节点类型
const visibleNodeTypes = new Set([
'approval',
'copy',
'data_update',
'delay',
'end',
'handle',
'start',
]);
// 扁平化流程节点(递归遍历树形结构)
function flattenNodes(
node: FlowNode | undefined,
result: FlowNode[] = [],
): FlowNode[] {
if (!node) return result;
// 只添加可见的节点类型
if (visibleNodeTypes.has(node.type)) {
result.push(node);
}
// 处理条件分支和并行分支
if (node.branches && node.branches.length > 0) {
// 对于条件分支,只显示第一个分支的节点(简化显示)
if (node.type === 'condition' && node.branches[0]?.children) {
flattenNodes(node.branches[0].children, result);
}
// 对于并行分支,显示所有分支的节点
if (node.type === 'parallel') {
for (const branch of node.branches) {
if (branch.children) {
flattenNodes(branch.children, result);
}
}
}
}
// 递归处理子节点
if (node.children) {
flattenNodes(node.children, result);
}
return result;
}
// 扁平化后的节点列表
const flatNodes = computed(() => {
if (!props.flowDefinition?.nodes) return [];
return flattenNodes(props.flowDefinition.nodes);
});
// 获取节点图标
function getNodeIcon(type: string) {
return nodeIconConfig[type] || Circle;
}
// 获取节点显示名称
function getNodeName(node: FlowNode) {
return getNodeDisplayName(node);
}
// 获取节点状态样式(预览时使用主题色)
function getStatusStyle() {
return {
'--node-color': 'var(--el-color-primary)',
'--node-bg': 'var(--el-color-primary-light-9)',
};
}
// 获取审批人类型文本
function getAssigneeTypeText(type: string): string {
const key = `workflow-designer.preview.assigneeTypes.${type}`;
const translated = $t(key);
return translated === key ? type : translated;
}
// 获取审批人信息
function getAssigneeInfo(node: FlowNode): null | {
assignees: string[];
field?: string;
level?: number;
type: string;
} {
const config = node.config as any;
if (!config) return null;
return {
type: config.assigneeType || 'user',
assignees: config.assignees || [],
level: config.assigneeLevel,
field: config.assigneeField,
};
}
// 加载角色下的用户
async function loadRoleUsers(roleIds: string[]) {
for (const roleId of roleIds) {
if (roleUsersCache.value[roleId]) continue;
try {
const res = await getRoleUsersApi(roleId, { page: 1, pageSize: 50 });
// RoleUser 接口返回 id 字段
roleUsersCache.value[roleId] = res.items.map((u: any) => String(u.id));
} catch {
roleUsersCache.value[roleId] = [];
}
}
}
// 加载部门下的用户
async function loadDeptUsers(deptIds: string[]) {
for (const deptId of deptIds) {
if (deptUsersCache.value[deptId]) continue;
try {
const res = await getDeptUsersApi(deptId, { page: 1, pageSize: 50 });
// DeptUser 接口返回 id 字段
deptUsersCache.value[deptId] = res.items.map((u: any) => String(u.id));
} catch {
deptUsersCache.value[deptId] = [];
}
}
}
// 获取角色下的所有用户ID
function getRoleUserIds(roleIds: string[]): string[] {
const userIds: string[] = [];
for (const roleId of roleIds) {
const users = roleUsersCache.value[roleId] || [];
userIds.push(...users);
}
return [...new Set(userIds)];
}
// 获取部门下的所有用户ID
function getDeptUserIds(deptIds: string[]): string[] {
const userIds: string[] = [];
for (const deptId of deptIds) {
const users = deptUsersCache.value[deptId] || [];
userIds.push(...users);
}
return [...new Set(userIds)];
}
// 监听流程定义变化,预加载角色和部门用户
watch(
() => props.flowDefinition,
async (flowDef) => {
if (!flowDef?.nodes) return;
const roleIds: string[] = [];
const deptIds: string[] = [];
// 收集所有角色和部门ID
function collectIds(node: FlowNode | undefined) {
if (!node) return;
const config = node.config as any;
if (config?.assigneeType === 'role' && config.assignees?.length) {
roleIds.push(...config.assignees);
}
if (config?.assigneeType === 'department' && config.assignees?.length) {
deptIds.push(...config.assignees);
}
if (node.children) collectIds(node.children);
if (node.branches) {
for (const branch of node.branches) {
if (branch.children) collectIds(branch.children);
}
}
}
collectIds(flowDef.nodes);
// 加载用户
if (roleIds.length > 0) await loadRoleUsers([...new Set(roleIds)]);
if (deptIds.length > 0) await loadDeptUsers([...new Set(deptIds)]);
},
{ immediate: true },
);
</script>
<template>
<div class="flow-path-preview">
<ElSkeleton v-if="loading" :rows="6" animated />
<template v-else-if="flatNodes.length > 0">
<!-- 流程节点列表 -->
<div class="progress-timeline">
<div
v-for="(node, index) in flatNodes"
:key="node.id"
class="progress-node"
:class="{ 'is-last': index === flatNodes.length - 1 }"
:style="getStatusStyle()"
>
<!-- 节点图标 -->
<div class="node-icon">
<component :is="getNodeIcon(node.type)" class="icon" />
</div>
<!-- 连接线 -->
<div v-if="index < flatNodes.length - 1" class="node-line"></div>
<!-- 节点内容 -->
<div
class="node-content bg-background min-h-12 rounded-[8px] px-4 py-2"
>
<div class="node-header">
<span class="node-name">{{ getNodeName(node) }}</span>
<ElTag type="info" size="small">
{{
$t('workflow-designer.detail.flowProgress.nodeStatus.pending')
}}
</ElTag>
</div>
<!-- 审批人信息 -->
<div
v-if="
['approval', 'handle', 'copy'].includes(node.type) &&
getAssigneeInfo(node)
"
class="node-handlers mb-2"
>
<div class="assignee-info">
<span class="assignee-type">
{{ getAssigneeTypeText(getAssigneeInfo(node)!.type) }}
</span>
<!-- 指定成员 -->
<div
v-if="
getAssigneeInfo(node)!.type === 'user' &&
getAssigneeInfo(node)!.assignees.length > 0
"
class="assignee-avatars"
>
<UserAvatar
v-for="userId in getAssigneeInfo(node)!.assignees"
:key="userId"
:user-id="userId"
:size="32"
:font-size="12"
:shadow="false"
auto-load
show-info
/>
</div>
<!-- 指定角色 - 显示角色下的用户 -->
<div
v-if="
getAssigneeInfo(node)!.type === 'role' &&
getAssigneeInfo(node)!.assignees.length > 0
"
class="assignee-avatars"
>
<template
v-if="
getRoleUserIds(getAssigneeInfo(node)!.assignees).length >
0
"
>
<UserAvatar
v-for="userId in getRoleUserIds(
getAssigneeInfo(node)!.assignees,
)"
:key="userId"
:user-id="userId"
:size="32"
:font-size="12"
:shadow="false"
auto-load
show-info
/>
</template>
<span v-else class="text-muted-foreground text-xs">{{
$t('workflow.start.loadingUsers')
}}</span>
</div>
<!-- 指定部门 - 显示部门下的用户 -->
<div
v-if="
getAssigneeInfo(node)!.type === 'department' &&
getAssigneeInfo(node)!.assignees.length > 0
"
class="assignee-avatars"
>
<template
v-if="
getDeptUserIds(getAssigneeInfo(node)!.assignees).length >
0
"
>
<UserAvatar
v-for="userId in getDeptUserIds(
getAssigneeInfo(node)!.assignees,
)"
:key="userId"
:user-id="userId"
:size="32"
:font-size="12"
:shadow="false"
auto-load
show-info
/>
</template>
<span v-else class="text-muted-foreground text-xs">{{
$t('workflow.start.loadingUsers')
}}</span>
</div>
<!-- 上级主管/经理 -->
<span
v-if="
getAssigneeInfo(node)!.type === 'superior' ||
getAssigneeInfo(node)!.type === 'manager'
"
class="assignee-level"
>
{{ $t('workflow-designer.preview.labels.level')
}}{{ getAssigneeInfo(node)!.level || 1
}}{{ $t('workflow-designer.preview.labels.levelSuffix')
}}{{
getAssigneeInfo(node)!.type === 'superior'
? $t('workflow-designer.preview.labels.superior')
: $t('workflow-designer.preview.labels.manager')
}}
</span>
<!-- 发起人 -->
<span
v-if="getAssigneeInfo(node)!.type === 'initiator'"
class="assignee-level"
>
{{ $t('workflow-designer.preview.assigneeTypes.initiator') }}
</span>
<!-- 表单字段 -->
<span
v-if="getAssigneeInfo(node)!.type === 'form_field'"
class="assignee-level"
>
{{
$t('workflow-designer.preview.assigneeTypes.form_field')
}}: {{ getAssigneeInfo(node)!.field }}
</span>
</div>
</div>
</div>
</div>
</div>
</template>
<ElEmpty
v-else
:description="$t('workflow-designer.detail.flowProgress.noData')"
/>
</div>
</template>
<style scoped>
.flow-path-preview {
padding: 16px;
}
/* 流程时间线 */
.progress-timeline {
position: relative;
}
.progress-node {
position: relative;
display: flex;
padding-bottom: 24px;
}
.progress-node.is-last {
padding-bottom: 0;
}
/* 节点图标 */
.node-icon {
z-index: 1;
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background: var(--node-bg);
border: 2px solid var(--node-color);
border-radius: 50%;
border-style: dashed;
margin-top: 8px;
}
.node-icon .icon {
width: 16px;
height: 16px;
color: var(--node-color);
}
/* 连接线 */
.node-line {
position: absolute;
top: 32px;
bottom: 0;
left: 15px;
width: 2px;
background: var(--el-border-color-light);
margin-top: 16px;
}
/* 节点内容 */
.node-content {
flex: 1;
min-width: 0;
margin-left: 12px;
}
.node-header {
display: flex;
gap: 8px;
align-items: center;
min-height: 32px;
}
.node-name {
font-size: 14px;
font-weight: 500;
color: var(--el-text-color-primary);
}
/* 审批人信息 */
.node-handlers {
padding: 12px;
margin-top: 8px;
background: var(--el-fill-color-lighter);
border-radius: 6px;
}
.assignee-info {
display: flex;
flex-direction: column;
gap: 8px;
}
.assignee-type {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.assignee-avatars {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.assignee-selector {
max-width: 100%;
}
.assignee-level {
font-size: 13px;
color: var(--el-text-color-primary);
}
</style>
@@ -1,908 +0,0 @@
<script lang="ts" setup>
import type { FlowProgress, ProgressNode } from '#/api/online-dev/workflow';
import type { NodeType } from '#/components/workflow/designer/types';
/**
* 流程进度 Tab 组件
* 展示完整的审批流程路径和状态
*/
import { computed, ref, watch } from 'vue';
import {
Circle,
Clock,
CornerDownLeft,
Forward,
GitBranch,
PenLine,
Play,
Square,
User,
Users,
} from '@vben/icons';
import { $t } from '@vben/locales';
import { ElEmpty, ElImage, ElMessage, ElSkeleton, ElTag } from 'element-plus';
import { getInstanceProgressApi } from '#/api/online-dev/workflow';
import { getNodeDisplayName } from '#/components/workflow/designer/utils/node-display';
import { UserAvatar } from '#/components/user-avatar/index';
import { getFileUrl } from '#/composables/useFileUrl';
interface Props {
instanceId: string;
}
const props = defineProps<Props>();
const loading = ref(false);
const progress = ref<FlowProgress | null>(null);
// 签名图片URL缓存
const signatureUrls = ref<Record<string, string>>({});
// 节点类型图标配置(静态)
const nodeIconConfig: Record<string, any> = {
start: Play,
end: Square,
approval: User,
handle: User,
copy: Forward,
condition: GitBranch,
parallel: Users,
delay: Clock,
notify: Forward,
service: Circle,
subflow: GitBranch,
data_update: PenLine,
};
// 节点状态配置
const statusConfig = computed<
Record<string, { bgColor: string; color: string; label: string }>
>(() => ({
completed: {
label: $t('workflow-designer.detail.flowProgress.nodeStatus.completed'),
color: 'var(--el-color-success)',
bgColor: 'var(--el-color-success-light-9)',
},
active: {
label: $t('workflow-designer.detail.flowProgress.nodeStatus.active'),
color: 'var(--el-color-primary)',
bgColor: 'var(--el-color-primary-light-9)',
},
pending: {
label: $t('workflow-designer.detail.flowProgress.nodeStatus.pending'),
color: 'var(--el-text-color-secondary)',
bgColor: 'var(--el-fill-color-light)',
},
skipped: {
label: $t('workflow-designer.detail.flowProgress.nodeStatus.skipped'),
color: 'var(--el-text-color-placeholder)',
bgColor: 'var(--el-fill-color-lighter)',
},
rejected: {
label: $t('workflow-designer.detail.flowProgress.nodeStatus.rejected'),
color: 'var(--el-color-danger)',
bgColor: 'var(--el-color-danger-light-9)',
},
}));
// 操作类型配置
const actionConfig = computed<Record<string, { label: string; type: string }>>(
() => ({
approve: {
label: $t('workflow-designer.detail.flowProgress.actions.approve'),
type: 'success',
},
reject: {
label: $t('workflow-designer.detail.flowProgress.actions.reject'),
type: 'danger',
},
return: {
label: $t('workflow-designer.detail.flowProgress.actions.return'),
type: 'warning',
},
transfer: {
label: $t('workflow-designer.detail.flowProgress.actions.transfer'),
type: 'warning',
},
delegate: {
label: $t('workflow-designer.detail.flowProgress.actions.delegate'),
type: 'info',
},
handle: {
label: $t('workflow-designer.detail.flowProgress.actions.handle'),
type: 'success',
},
add_sign: {
label: $t('workflow-designer.detail.flowProgress.actions.add_sign'),
type: 'warning',
},
}),
);
// 加签类型配置
const signTypeConfig = computed<Record<string, string>>(() => ({
before: $t('workflow-designer.detail.flowProgress.signTypes.before'),
after: $t('workflow-designer.detail.flowProgress.signTypes.after'),
parallel: $t('workflow-designer.detail.flowProgress.signTypes.parallel'),
}));
// 额外操作类型配置
const extraActionConfig = computed<
Record<string, { label: string; type: string }>
>(() => ({
add_sign: {
label: $t('workflow-designer.detail.flowProgress.extraActions.add_sign'),
type: 'warning',
},
reduce_sign: {
label: $t('workflow-designer.detail.flowProgress.extraActions.reduce_sign'),
type: 'danger',
},
transfer: {
label: $t('workflow-designer.detail.flowProgress.extraActions.transfer'),
type: 'warning',
},
delegate: {
label: $t('workflow-designer.detail.flowProgress.extraActions.delegate'),
type: 'info',
},
return: {
label: $t('workflow-designer.detail.flowProgress.extraActions.return'),
type: 'danger',
},
}));
// 加载数据
async function loadProgress() {
if (!props.instanceId) return;
loading.value = true;
try {
progress.value = await getInstanceProgressApi(props.instanceId);
// 加载签名图片URL
if (progress.value?.nodes) {
await loadSignatureUrls(progress.value.nodes);
}
} catch (error: any) {
ElMessage.error(
error?.message || $t('workflow-designer.detail.flowProgress.loadFailed'),
);
} finally {
loading.value = false;
}
}
// 加载签名图片URL
async function loadSignatureUrls(nodes: ProgressNode[]) {
const fileIds: string[] = [];
for (const node of nodes) {
for (const handler of node.handlers || []) {
if ((handler as any).signature_file_id) {
fileIds.push((handler as any).signature_file_id);
}
}
}
if (fileIds.length === 0) return;
const results = await Promise.all(
fileIds.map(async (fileId) => {
try {
const url = await getFileUrl(fileId);
return { fileId, url };
} catch {
return { fileId, url: '' };
}
}),
);
for (const { fileId, url } of results) {
if (url) {
signatureUrls.value[fileId] = url;
}
}
}
// 监听 instanceId 变化
watch(
() => props.instanceId,
(newId) => {
if (newId) {
loadProgress();
}
},
{ immediate: true },
);
// 获取节点图标
function getNodeIcon(type: string) {
return nodeIconConfig[type] || Circle;
}
// 获取节点显示名称
function getNodeName(node: ProgressNode) {
return getNodeDisplayName({
name: node.name,
type: node.type as NodeType,
});
}
// 获取节点状态样式
function getStatusStyle(status: string) {
const config = statusConfig.value[status] ?? statusConfig.value.pending!;
return {
'--node-color': config?.color,
'--node-bg': config?.bgColor,
};
}
// 获取节点状态标签
function getStatusLabel(status: string) {
return statusConfig.value[status]?.label || status;
}
// 获取节点状态 ElTag type
function getStatusTagType(status: string): string {
const typeMap: Record<string, string> = {
completed: 'success',
active: 'primary',
rejected: 'danger',
pending: 'info',
skipped: 'info',
};
return typeMap[status] || 'info';
}
// 获取操作标签
function getActionLabel(action: string) {
return actionConfig.value[action]?.label || action;
}
// 获取操作 ElTag type
function getActionTagType(action: string): string {
return actionConfig.value[action]?.type || 'info';
}
// 获取额外操作标签
function getExtraActionLabel(action: { sign_type: string; type: string }) {
if (action.type === 'add_sign') {
return (
signTypeConfig.value[action.sign_type] ||
$t('workflow-designer.detail.flowProgress.extraActions.add_sign')
);
}
return extraActionConfig.value[action.type]?.label || action.type;
}
// 获取额外操作 ElTag type
function getExtraActionTagType(type: string): string {
return extraActionConfig.value[type]?.type || 'info';
}
// 格式化时间
function formatTime(time: null | string | undefined) {
if (!time) return '';
return time;
}
// 是否显示处理人详情
function hasHandlerDetails(node: ProgressNode) {
return node.handlers.length > 0 || node.extra_actions.length > 0;
}
// 需要显示的节点类型(过滤掉条件分支、并行分支、延时、通知、服务调用、子流程等非人工处理节点)
const visibleNodeTypes = new Set([
'approval',
'copy',
'data_update',
'delay',
'end',
'handle',
'start',
]);
const filteredNodes = computed(() => {
if (!progress.value) return [];
return progress.value.nodes.filter((node) => visibleNodeTypes.has(node.type));
});
</script>
<template>
<div class="flow-progress m-4 rounded-[8px]">
<ElSkeleton v-if="loading" :rows="8" animated />
<template v-else-if="progress && filteredNodes.length > 0">
<!-- 驳回记录提示 -->
<div v-if="progress.returns.length > 0" class="return-records">
<div class="return-title">
<CornerDownLeft class="return-icon" />
{{ $t('workflow-designer.detail.flowProgress.returnRecords') }}
</div>
<div
v-for="(ret, index) in progress.returns"
:key="index"
class="return-item"
>
<span class="return-from">{{ ret.from_node_name }}</span>
<CornerDownLeft class="return-arrow" />
<span class="return-to">{{ ret.to_node_name }}</span>
<span class="return-info">
{{ ret.operator_name }} · {{ ret.time }}
</span>
<span v-if="ret.reason" class="return-reason">{{ ret.reason }}</span>
</div>
</div>
<!-- 流程节点列表 -->
<div class="progress-timeline">
<div
v-for="(node, index) in filteredNodes"
:key="node.id"
class="progress-node"
:class="[
`status-${node.status}`,
{ 'is-last': index === filteredNodes.length - 1 },
]"
:style="getStatusStyle(node.status)"
>
<!-- 节点图标 -->
<div class="node-icon">
<component :is="getNodeIcon(node.type)" class="icon" />
</div>
<!-- 连接线 -->
<div v-if="index < filteredNodes.length - 1" class="node-line"></div>
<!-- 节点内容 -->
<div
class="node-content bg-background min-h-12 rounded-[8px] px-4 py-2"
>
<div class="node-header">
<span class="node-name">{{ getNodeName(node) }}</span>
<ElTag :type="getStatusTagType(node.status) as any" size="small">
{{ getStatusLabel(node.status) }}
</ElTag>
<span v-if="node.completed_at" class="node-time">
{{ formatTime(node.completed_at) }}
</span>
</div>
<!-- 延时节点信息 -->
<div
v-if="
node.type === 'delay' &&
node.status === 'active' &&
node.delay_until
"
class="delay-info"
>
<Clock class="delay-icon" />
<span>{{
$t('workflow-designer.detail.flowProgress.delayWaiting')
}}</span>
<span class="delay-time">{{ node.delay_until }}</span>
</div>
<!-- 处理人信息(审批/办理/抄送节点) -->
<div v-if="hasHandlerDetails(node)" class="node-handlers mb-2">
<!-- 处理人列表 -->
<div class="handlers-list">
<div
v-for="handler in node.handlers"
:key="handler.user_id || handler.user_name"
class="handler-item"
>
<div class="handler-avatar-wrapper">
<UserAvatar
v-if="handler.user_id"
:user-id="handler.user_id"
:name="handler.user_name"
:size="36"
:font-size="13"
:show-info="true"
:shadow="false"
auto-load
/>
<template v-else>
<div class="handler-placeholder">
{{ handler.user_name.charAt(0) }}
</div>
<div class="handler-name-text">
{{ handler.user_name }}
</div>
</template>
</div>
<!-- 状态标签 -->
<ElTag
v-if="handler.action"
:type="getActionTagType(handler.action) as any"
size="small"
>
{{ getActionLabel(handler.action) }}
</ElTag>
<ElTag
v-else-if="handler.status === 'pending'"
type="warning"
size="small"
>
{{ $t('workflow-designer.detail.flowProgress.pending') }}
</ElTag>
<ElTag
v-else-if="handler.status === 'waiting'"
type="info"
size="small"
>
{{ $t('workflow-designer.detail.flowProgress.waiting') }}
</ElTag>
<ElTag
v-else-if="handler.status === 'copied'"
type="success"
size="small"
>
{{ $t('workflow-designer.detail.flowProgress.copied') }}
</ElTag>
<ElTag
v-else-if="handler.status === 'skipped'"
type="info"
size="small"
>
{{ $t('workflow-designer.detail.flowProgress.skipped') }}
</ElTag>
<div v-if="handler.handled_at" class="handler-time">
{{ formatTime(handler.handled_at) }}
</div>
</div>
</div>
<!-- 处理意见和签名(如果有) -->
<div
v-for="handler in node.handlers.filter(
(h) => h.comment || (h as any).signature_file_id,
)"
:key="`comment-${handler.user_id}`"
class="handler-comment-item"
>
<div v-if="handler.comment" class="comment-content">
<span class="comment-user">{{ handler.user_name }}</span>
<span class="comment-text">{{ handler.comment }}</span>
</div>
<!-- 签名图片 -->
<div
v-if="
(handler as any).signature_file_id &&
signatureUrls[(handler as any).signature_file_id]
"
class="handler-signature"
>
<span class="signature-label"
>{{ $t('workflow.pending.signature') }}:</span
>
<ElImage
:src="
signatureUrls[(handler as any).signature_file_id] || ''
"
:preview-src-list="[
signatureUrls[(handler as any).signature_file_id] || '',
]"
fit="contain"
class="signature-image"
/>
</div>
</div>
<!-- 额外操作(驳回等,加签/转交/委派已在 handlers 中显示) -->
<div
v-for="(action, actionIndex) in node.extra_actions.filter(
(a) =>
![
'add_sign',
'reduce_sign',
'transfer',
'delegate',
].includes(a.type),
)"
:key="`action-${actionIndex}`"
class="extra-action-item"
>
<div class="action-row">
<!-- 操作发起人 -->
<UserAvatar
v-if="action.from_user_id"
:user-id="action.from_user_id"
:name="action.from_user_name"
:size="24"
:font-size="10"
:show-info="false"
:shadow="false"
auto-load
/>
<span class="action-user-name">{{
action.from_user_name
}}</span>
<!-- 操作类型标签 -->
<ElTag
:type="getExtraActionTagType(action.type) as any"
size="small"
>
{{ getExtraActionLabel(action) }}
</ElTag>
<!-- 目标用户(非驳回操作) -->
<template
v-if="action.type !== 'return' && action.to_user_id"
>
<span class="action-arrow">→</span>
<UserAvatar
:user-id="action.to_user_id"
:name="action.to_user_name"
:size="24"
:font-size="10"
:show-info="false"
:shadow="false"
auto-load
/>
<span class="action-user-name">{{
action.to_user_name
}}</span>
</template>
<!-- 驳回目标节点 -->
<template v-else-if="action.type === 'return'">
<span class="action-arrow">→</span>
<span class="action-target-node">{{
action.to_user_name
}}</span>
</template>
<span class="action-time">{{ action.time }}</span>
</div>
<!-- 操作说明/驳回原因 -->
<div v-if="action.comment" class="action-comment">
{{ action.comment }}
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<ElEmpty
v-else
:description="$t('workflow-designer.detail.flowProgress.noData')"
/>
</div>
</template>
<style scoped>
.flow-progress {
padding: 16px;
}
/* 驳回记录 */
.return-records {
padding: 12px 16px;
margin-bottom: 20px;
background: var(--el-color-warning-light-9);
/* border-left: 3px solid var(--el-color-warning); */
border-radius: 8px;
}
.return-title {
display: flex;
gap: 6px;
align-items: center;
margin-bottom: 8px;
font-size: 13px;
font-weight: 500;
color: var(--el-color-warning-dark-2);
}
.return-icon {
width: 16px;
height: 16px;
}
.return-item {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
padding: 6px 0;
font-size: 12px;
color: var(--el-text-color-regular);
}
.return-item + .return-item {
border-top: 1px dashed var(--el-border-color-lighter);
}
.return-from,
.return-to {
font-weight: 500;
color: var(--el-text-color-primary);
}
.return-arrow {
width: 14px;
height: 14px;
color: var(--el-color-warning);
}
.return-info {
color: var(--el-text-color-secondary);
}
.return-reason {
flex-basis: 100%;
padding-left: 20px;
font-style: italic;
color: var(--el-text-color-secondary);
}
/* 流程时间线 */
.progress-timeline {
position: relative;
}
.progress-node {
position: relative;
display: flex;
padding-bottom: 24px;
}
.progress-node.is-last {
padding-bottom: 0;
}
/* 节点图标 */
.node-icon {
z-index: 1;
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background: var(--node-bg);
border: 2px solid var(--node-color);
border-radius: 50%;
}
.node-icon .icon {
width: 16px;
height: 16px;
color: var(--node-color);
}
/* 连接线 */
.node-line {
position: absolute;
top: 32px;
bottom: 0;
left: 15px;
width: 2px;
background: var(--el-border-color-light);
}
.progress-node.status-completed .node-line {
background: var(--el-color-success-light-5);
}
.progress-node.status-active .node-line {
background: linear-gradient(
to bottom,
var(--el-color-primary-light-5) 0%,
var(--el-border-color-light) 100%
);
}
/* 节点内容 */
.node-content {
flex: 1;
min-width: 0;
margin-left: 12px;
}
.node-header {
display: flex;
gap: 8px;
align-items: center;
min-height: 32px;
}
.node-name {
font-size: 14px;
font-weight: 500;
color: var(--el-text-color-primary);
}
.node-time {
margin-left: auto;
font-size: 12px;
color: var(--el-text-color-secondary);
}
/* 处理人信息 */
.node-handlers {
padding: 12px;
margin-top: 8px;
background: var(--el-fill-color-lighter);
border-radius: 6px;
}
.handlers-list {
display: flex;
flex-wrap: wrap;
gap: 16px;
}
.handler-item {
display: flex;
flex-direction: column;
gap: 4px;
align-items: center;
min-width: 70px;
}
.handler-avatar-wrapper {
display: flex;
flex-direction: column;
align-items: center;
}
.handler-placeholder {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
font-size: 14px;
font-weight: 500;
color: var(--el-color-info);
background: var(--el-color-info-light-7);
border-radius: 50%;
}
.handler-name-text {
max-width: 70px;
margin-top: 4px;
overflow: hidden;
text-overflow: ellipsis;
font-size: 12px;
color: var(--el-text-color-primary);
text-align: center;
white-space: nowrap;
}
.handler-time {
font-size: 10px;
color: var(--el-text-color-secondary);
}
/* 处理意见 */
.handler-comment-item {
padding: 8px 10px;
margin-top: 10px;
font-size: 12px;
background: var(--el-bg-color);
border-radius: 4px;
}
.comment-user {
font-weight: 500;
color: var(--el-text-color-primary);
}
.comment-text {
color: var(--el-text-color-regular);
}
/* 签名图片 */
.handler-signature {
display: flex;
gap: 8px;
align-items: flex-start;
margin-top: 8px;
}
.handler-signature .signature-label {
flex-shrink: 0;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.handler-signature .signature-image {
max-width: 120px;
max-height: 60px;
border-radius: 4px;
background: #fff;
}
/* 额外操作 */
.extra-action-item {
padding: 8px 0;
border-top: 1px dashed var(--el-border-color-lighter);
}
.action-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.action-user-name {
font-size: 13px;
color: var(--el-text-color-primary);
}
.action-arrow {
margin: 0 2px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.action-target-node {
font-size: 13px;
font-weight: 500;
color: var(--el-color-primary);
}
.action-time {
margin-left: auto;
font-size: 11px;
color: var(--el-text-color-secondary);
}
.action-comment {
padding: 6px 8px;
margin-top: 6px;
margin-left: 30px;
font-size: 12px;
color: var(--el-text-color-regular);
background: var(--el-bg-color);
border-radius: 4px;
}
/* 状态样式 */
.progress-node.status-skipped .node-icon {
opacity: 0.5;
}
.progress-node.status-skipped .node-content {
opacity: 0.6;
}
.progress-node.status-pending .node-icon {
border-style: dashed;
}
/* 延时节点信息 */
.delay-info {
display: flex;
gap: 6px;
align-items: center;
padding: 8px 12px;
margin-top: 8px;
font-size: 12px;
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
border-radius: 6px;
}
.delay-icon {
width: 14px;
height: 14px;
flex-shrink: 0;
}
.delay-time {
font-weight: 500;
}
</style>
@@ -1,208 +0,0 @@
<script lang="ts" setup>
import type { Component } from 'vue';
import type {
WorkflowInstance,
WorkflowInstanceListItem,
} from '#/api/online-dev/workflow';
import { computed, ref, watch } from 'vue';
import { FileText, Route, Workflow } from '@vben/icons';
import { $t } from '@vben/locales';
import { ElEmpty } from 'element-plus';
import FlowProgressTab from '#/components/workflow/detial/FlowProgressTab.vue';
import FlowchartTab from '#/components/workflow/detial/tabs/FlowchartTab.vue';
import FormTab from '#/components/workflow/detial/tabs/FormTab.vue';
import ProgressTab from '#/components/workflow/detial/tabs/ProgressTab.vue';
import { ZqTabs } from '#/components/zq-tabs';
interface TabItem {
key: string;
label: string;
icon?: Component;
}
interface InstanceLike {
id: string;
title?: string;
workflow_name?: string;
}
interface Props {
instance: InstanceLike | null | WorkflowInstanceListItem;
defaultTab?: string;
extraTabs?: TabItem[];
emptyText?: string;
}
const props = withDefaults(defineProps<Props>(), {
defaultTab: 'progress',
extraTabs: () => [],
emptyText: '',
});
const emit = defineEmits<{
loaded: [instance: WorkflowInstance];
}>();
// 状态
const activeTab = ref(props.defaultTab);
const instanceDetail = ref<null | WorkflowInstance>(null);
// Tab 加载状态(懒加载)
const loadedTabs = ref<Set<string>>(new Set([props.defaultTab]));
// 默认 Tab 配置
const defaultTabItems = computed(() => [
{
key: 'progress',
label: $t('workflow-designer.detail.tabs.progress'),
icon: Workflow,
},
{
key: 'form',
label: $t('workflow-designer.detail.tabs.form'),
icon: FileText,
},
{
key: 'flowchart',
label: $t('workflow-designer.detail.tabs.flowchart'),
icon: Workflow,
},
{
key: 'flowProgress',
label: $t('workflow-designer.detail.tabs.flowProgress'),
icon: Route,
},
]);
// 合并 Tab 配置
const tabItems = computed(() => {
// 如果有额外的 tabs,插入到最前面
if (props.extraTabs.length > 0) {
return [...props.extraTabs, ...defaultTabItems.value];
}
return defaultTabItems.value;
});
// 空状态文本
const emptyDescription = computed(() => {
return props.emptyText || $t('workflow.initiated.selectInstance');
});
// ProgressTab 加载完成回调
function handleProgressLoaded(inst: WorkflowInstance) {
instanceDetail.value = inst;
emit('loaded', inst);
}
// 监听实例变化
watch(
() => props.instance?.id,
(newId, oldId) => {
if (newId && newId !== oldId) {
// 重置状态
activeTab.value = props.defaultTab;
instanceDetail.value = null;
loadedTabs.value = new Set([props.defaultTab]);
}
},
);
// 监听 tab 切换,记录已加载的 tab
watch(activeTab, (tab) => {
loadedTabs.value.add(tab);
});
// 暴露给父组件
defineExpose({
activeTab,
instanceDetail,
});
</script>
<template>
<div class="instance-detail-panel h-[calc(100vh-120px)] overflow-hidden">
<template v-if="instance">
<div class="detail-content flex h-full flex-col overflow-hidden">
<!-- Tabs 头部 -->
<div class="tabs-header flex-shrink-0 px-4 py-4">
<ZqTabs v-model="activeTab" :items="tabItems" class="w-fit" />
</div>
<!-- Tab 内容区域 -->
<div
:key="instance.id"
class="tabs-content bg-background-deep mx-4 mb-4 min-h-0 flex-1 overflow-hidden rounded-[8px]"
>
<!-- 额外的 Tab 内容插槽 -->
<template v-for="tab in extraTabs" :key="tab.key">
<div v-show="activeTab === tab.key" class="h-full">
<slot
:name="`tab-${tab.key}`"
:instance="instance"
:instance-detail="instanceDetail"
></slot>
</div>
</template>
<!-- 流程进度 -->
<div v-show="activeTab === 'progress'" class="h-full">
<ProgressTab
:instance-id="instance.id"
@loaded="handleProgressLoaded"
/>
</div>
<!-- 表单内容 -->
<div v-show="activeTab === 'form'" class="h-full">
<FormTab
v-if="loadedTabs.has('form')"
:instance-id="instance.id"
:form-code="instanceDetail?.form_code"
/>
</div>
<!-- 流程图 -->
<div v-show="activeTab === 'flowchart'" class="h-full">
<FlowchartTab
v-if="loadedTabs.has('flowchart')"
:instance-id="instance.id"
:workflow-id="instanceDetail?.workflow_id"
:current-node-id="instanceDetail?.current_node_id"
:instance-status="instanceDetail?.status"
/>
</div>
<!-- 审批路径 -->
<div
v-show="activeTab === 'flowProgress'"
class="h-full overflow-auto"
>
<FlowProgressTab
v-if="loadedTabs.has('flowProgress')"
:instance-id="instance.id"
/>
</div>
</div>
</div>
</template>
<div v-else class="flex h-full items-center justify-center">
<ElEmpty :description="emptyDescription" />
</div>
</div>
</template>
<style scoped>
.detail-content {
overflow: hidden;
}
.tabs-content {
padding: 0;
}
</style>
@@ -1,327 +0,0 @@
<script setup lang="ts">
import type {
DocumentTemplateListItem,
GeneratedDocument,
} from '#/api/online-dev/document-generator';
import { ref, watch } from 'vue';
import { $t } from '@vben/locales';
import { Download, FileText, Plus, RefreshCw } from '@vben/icons';
import {
ElButton,
ElDialog,
ElEmpty,
ElMessage,
ElOption,
ElSelect,
ElTable,
ElTableColumn,
ElTag,
} from 'element-plus';
import {
downloadDocumentApi,
generateDocumentApi,
getDocumentListApi,
getTemplateListApi,
regenerateDocumentApi,
} from '#/api/online-dev/document-generator';
const props = defineProps<{
formCode?: string;
instanceId: string;
workflowCode?: string;
}>();
// 状态
const loading = ref(false);
const documents = ref<GeneratedDocument[]>([]);
const templates = ref<DocumentTemplateListItem[]>([]);
// 生成弹窗
const generateDialogVisible = ref(false);
const selectedTemplateId = ref('');
const generating = ref(false);
// 加载文档列表
async function loadDocuments() {
if (!props.instanceId) return;
loading.value = true;
try {
const res = await getDocumentListApi({
instanceId: props.instanceId,
pageSize: 100,
});
documents.value = res.items;
} catch (error) {
console.error('Load documents failed:', error);
} finally {
loading.value = false;
}
}
// 加载可用模板
async function loadTemplates() {
try {
const res = await getTemplateListApi({
status: 'published',
workflowCode: props.workflowCode || undefined,
pageSize: 100,
});
templates.value = res.items;
} catch (error) {
console.error('Load templates failed:', error);
}
}
// 打开生成弹窗
function handleOpenGenerate() {
selectedTemplateId.value = '';
generateDialogVisible.value = true;
loadTemplates();
}
// 生成文档
async function handleGenerate() {
if (!selectedTemplateId.value) {
ElMessage.warning($t('documentGenerator.pleaseSelectTemplate'));
return;
}
generating.value = true;
try {
await generateDocumentApi({
template_id: selectedTemplateId.value,
instance_id: props.instanceId,
});
ElMessage.success($t('documentGenerator.generateSuccess'));
generateDialogVisible.value = false;
loadDocuments();
} catch (error: any) {
ElMessage.error(error?.message || $t('documentGenerator.generateFailed'));
} finally {
generating.value = false;
}
}
// 下载文档
async function handleDownload(doc: GeneratedDocument) {
try {
const blob = await downloadDocumentApi(doc.id);
const url = URL.createObjectURL(blob as Blob);
const a = document.createElement('a');
a.href = url;
a.download = `${doc.document_name}.pdf`;
a.click();
URL.revokeObjectURL(url);
} catch (error: any) {
ElMessage.error(error?.message || $t('common.downloadFailed'));
}
}
// 重新生成
async function handleRegenerate(doc: GeneratedDocument) {
try {
await regenerateDocumentApi(doc.id);
ElMessage.success($t('documentGenerator.regenerateSuccess'));
loadDocuments();
} catch (error: any) {
ElMessage.error(error?.message || $t('documentGenerator.regenerateFailed'));
}
}
// 格式化文件大小
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
// 监听实例变化
watch(
() => props.instanceId,
(newId) => {
if (newId) {
loadDocuments();
}
},
{ immediate: true },
);
</script>
<template>
<div class="document-tab">
<!-- 工具栏 -->
<div class="toolbar">
<ElButton type="primary" @click="handleOpenGenerate">
<Plus class="mr-1 h-4 w-4" />
{{ $t('documentGenerator.generateDocument') }}
</ElButton>
<ElButton @click="loadDocuments">
<RefreshCw class="mr-1 h-4 w-4" />
{{ $t('common.refresh') }}
</ElButton>
</div>
<!-- 文档列表 -->
<div v-loading="loading" class="document-list">
<ElTable v-if="documents.length > 0" :data="documents" stripe>
<ElTableColumn
prop="document_name"
:label="$t('documentGenerator.documentName')"
min-width="200"
>
<template #default="{ row }">
<div class="flex items-center gap-2">
<FileText class="text-primary h-4 w-4" />
<span>{{ row.document_name }}</span>
</div>
</template>
</ElTableColumn>
<ElTableColumn
prop="template_name"
:label="$t('documentGenerator.templateName')"
width="150"
/>
<ElTableColumn
prop="page_count"
:label="$t('documentGenerator.pageCount')"
width="80"
align="center"
/>
<ElTableColumn
prop="file_size"
:label="$t('documentGenerator.fileSize')"
width="100"
align="center"
>
<template #default="{ row }">
{{ formatFileSize(row.file_size) }}
</template>
</ElTableColumn>
<ElTableColumn
prop="generate_type"
:label="$t('documentGenerator.generateType')"
width="100"
align="center"
>
<template #default="{ row }">
<ElTag
:type="row.generate_type === 'auto' ? 'success' : 'info'"
size="small"
>
{{
row.generate_type === 'auto'
? $t('documentGenerator.auto')
: $t('documentGenerator.manual')
}}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn
prop="sys_create_datetime"
:label="$t('documentGenerator.generateTime')"
width="160"
>
<template #default="{ row }">
{{ row.sys_create_datetime?.slice(0, 16).replace('T', ' ') }}
</template>
</ElTableColumn>
<ElTableColumn :label="$t('common.actions')" width="150" fixed="right">
<template #default="{ row }">
<div class="flex gap-2">
<ElButton link type="primary" @click="handleDownload(row)">
<Download class="mr-1 h-4 w-4" />
{{ $t('common.download') }}
</ElButton>
<ElButton link @click="handleRegenerate(row)">
<RefreshCw class="mr-1 h-4 w-4" />
</ElButton>
</div>
</template>
</ElTableColumn>
</ElTable>
<ElEmpty v-else :description="$t('documentGenerator.noDocuments')" />
</div>
<!-- 生成文档弹窗 -->
<ElDialog
v-model="generateDialogVisible"
:title="$t('documentGenerator.generateDocument')"
width="500px"
>
<div class="generate-form">
<div class="form-item">
<label>{{ $t('documentGenerator.selectTemplate') }}</label>
<ElSelect
v-model="selectedTemplateId"
:placeholder="$t('documentGenerator.pleaseSelectTemplate')"
style="width: 100%"
>
<ElOption
v-for="tpl in templates"
:key="tpl.id"
:label="tpl.name"
:value="tpl.id"
>
<div class="flex items-center justify-between">
<span>{{ tpl.name }}</span>
<ElTag size="small" type="info">{{ tpl.category }}</ElTag>
</div>
</ElOption>
</ElSelect>
</div>
</div>
<template #footer>
<ElButton @click="generateDialogVisible = false">
{{ $t('common.cancel') }}
</ElButton>
<ElButton type="primary" :loading="generating" @click="handleGenerate">
{{ $t('documentGenerator.generate') }}
</ElButton>
</template>
</ElDialog>
</div>
</template>
<style scoped>
.document-tab {
height: 100%;
display: flex;
flex-direction: column;
padding: 16px;
}
.toolbar {
display: flex;
gap: 12px;
margin-bottom: 16px;
flex-shrink: 0;
}
.document-list {
flex: 1;
overflow: auto;
}
.generate-form {
padding: 16px 0;
}
.form-item {
margin-bottom: 16px;
}
.form-item label {
display: block;
margin-bottom: 8px;
font-size: 14px;
color: var(--el-text-color-regular);
}
</style>
@@ -1,234 +0,0 @@
<script lang="ts" setup>
import type { WorkflowLog } from '#/api/online-dev/workflow';
/**
* 流程图 Tab
*/
import { computed, onMounted, ref } from 'vue';
import { ElEmpty, ElSkeleton, ElSkeletonItem } from 'element-plus';
import {
getInstanceLogsApi,
getWorkflowDetailApi,
} from '#/api/online-dev/workflow';
import FlowPreviewContent from '#/components/workflow/designer/components/FlowPreviewContent.vue';
interface Props {
instanceId: string;
workflowId?: string;
currentNodeId?: string;
instanceStatus?: string;
}
const props = defineProps<Props>();
// 状态
const loading = ref(false);
const flowDefinition = ref<any>(null);
const workflowName = ref('');
const logs = ref<WorkflowLog[]>([]);
// 计算节点状态
const nodeStatuses = computed(() => {
const statuses: Record<
string,
'active' | 'completed' | 'pending' | 'rejected'
> = {};
if (!flowDefinition.value?.nodes) return statuses;
// 从日志中提取已执行的节点
const executedNodes = new Set<string>();
const rejectedNodes = new Set<string>();
for (const log of logs.value) {
if (log.node_id) {
executedNodes.add(log.node_id);
if (log.action === 'reject') {
rejectedNodes.add(log.node_id);
}
}
}
// 流程是否已结束
const isFinished =
props.instanceStatus === 'approved' ||
props.instanceStatus === 'rejected' ||
props.instanceStatus === 'cancelled';
// 遍历流程定义,为所有节点设置状态
function processNode(node: any) {
if (!node) return;
const nodeId = node.id;
if (node.type === 'start') {
statuses[nodeId] = 'completed';
} else if (rejectedNodes.has(nodeId)) {
statuses[nodeId] = 'rejected';
} else if (nodeId === props.currentNodeId && !isFinished) {
// 如果节点已有执行日志且不是当前活跃节点(条件分支推进遗留),标记为已完成
statuses[nodeId] = executedNodes.has(nodeId) ? 'completed' : 'active';
} else if (executedNodes.has(nodeId)) {
statuses[nodeId] = 'completed';
} else if (
node.type === 'end' &&
isFinished &&
props.instanceStatus === 'approved'
) {
statuses[nodeId] = 'completed';
} else {
statuses[nodeId] = 'pending';
}
if (node.children) {
processNode(node.children);
}
if (node.branches) {
for (const branch of node.branches) {
if (branch.children) {
processNode(branch.children);
}
}
}
}
processNode(flowDefinition.value.nodes);
return statuses;
});
// 加载数据
async function loadData() {
if (!props.workflowId) return;
loading.value = true;
try {
const [workflowRes, logsRes] = await Promise.all([
getWorkflowDetailApi(props.workflowId),
getInstanceLogsApi(props.instanceId).catch(() => []),
]);
flowDefinition.value = workflowRes.flow_definition;
workflowName.value = workflowRes.name;
logs.value = logsRes || [];
} catch {
// 忽略加载失败
} finally {
loading.value = false;
}
}
onMounted(() => {
loadData();
});
defineExpose({
reload: loadData,
});
</script>
<template>
<div class="flowchart-tab">
<!-- 骨架屏 -->
<div v-if="loading" class="flowchart-skeleton">
<ElSkeleton animated>
<template #template>
<div class="skeleton-flowchart">
<!-- 模拟流程图节点 -->
<div class="skeleton-node start">
<ElSkeletonItem
variant="circle"
style="width: 60px; height: 60px"
/>
</div>
<div class="skeleton-line"></div>
<div class="skeleton-node">
<ElSkeletonItem
variant="rect"
style="width: 120px; height: 60px; border-radius: 8px"
/>
</div>
<div class="skeleton-line"></div>
<div class="skeleton-node">
<ElSkeletonItem
variant="rect"
style="width: 120px; height: 60px; border-radius: 8px"
/>
</div>
<div class="skeleton-line"></div>
<div class="skeleton-node">
<ElSkeletonItem
variant="rect"
style="width: 120px; height: 60px; border-radius: 8px"
/>
</div>
<div class="skeleton-line"></div>
<div class="skeleton-node end">
<ElSkeletonItem
variant="circle"
style="width: 60px; height: 60px"
/>
</div>
</div>
</template>
</ElSkeleton>
</div>
<!-- 内容 -->
<div v-else-if="flowDefinition?.nodes" class="flowchart-content">
<FlowPreviewContent
:flow-definition="flowDefinition"
:flow-name="workflowName"
:show-header="false"
:node-statuses="nodeStatuses"
height="100%"
/>
</div>
<ElEmpty v-else description="暂无流程图" />
</div>
</template>
<style scoped>
.flowchart-tab {
display: flex;
flex-direction: column;
height: 100%;
}
/* 骨架屏 */
.flowchart-skeleton {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
padding: 40px;
}
.skeleton-flowchart {
display: flex;
flex-direction: column;
gap: 0;
align-items: center;
}
.skeleton-node {
display: flex;
justify-content: center;
}
.skeleton-line {
width: 2px;
height: 30px;
background: var(--el-border-color);
}
/* 内容 */
.flowchart-content {
flex: 1;
min-height: 0;
overflow: hidden;
}
</style>
@@ -1,195 +0,0 @@
<script lang="ts" setup>
/**
* 表单内容 Tab
*/
import { computed, onMounted, ref } from 'vue';
import { $t } from '@vben/locales';
import {
ElEmpty,
ElForm,
ElMessage,
ElScrollbar,
ElSkeleton,
ElSkeletonItem,
} from 'element-plus';
import { getFormByCodeApi } from '#/api/online-dev/form-manager';
import { getInstanceFormDataApi } from '#/api/online-dev/workflow';
import PreviewItem from '#/components/form-design/components/PreviewItem.vue';
interface Props {
instanceId: string;
formCode?: string;
}
const props = defineProps<Props>();
// 状态
const loading = ref(false);
const formData = ref<Record<string, any>>({});
const formConfig = ref<any>(null);
// 表单项(只读模式)
const formItems = computed(() => {
if (!formConfig.value?.items) return [];
return processFormItems(formConfig.value.items);
});
// 递归处理表单项为只读
function processFormItems(items: any[]): any[] {
return items.map((item) => {
const newItem = JSON.parse(JSON.stringify(item));
newItem.props = { ...newItem.props, disabled: true };
// 子表禁用操作
if (item.type === 'sub-table') {
newItem.props = {
...newItem.props,
addable: false,
deletable: false,
sortable: false,
copyable: false,
};
}
if (item.columns) {
newItem.columns = item.columns.map((col: any) => ({
...col,
children: processFormItems(col.children || []),
}));
}
if (item.children) {
newItem.children = processFormItems(item.children);
}
return newItem;
});
}
// 加载数据
async function loadData() {
if (!props.instanceId || !props.formCode) return;
loading.value = true;
try {
const [formMetaRes, formDataRes] = await Promise.all([
getFormByCodeApi(props.formCode).catch(() => null),
getInstanceFormDataApi(props.instanceId).catch(() => ({})),
]);
if (formMetaRes) {
formConfig.value = formMetaRes.form_config;
}
// 处理表单数据
const data = (formDataRes as Record<string, any>) || {};
Object.keys(data).forEach((key) => {
if (key !== 'sub_tables') {
formData.value[key] = data[key];
}
});
if (data.sub_tables) {
Object.keys(data.sub_tables).forEach((tableName: string) => {
formData.value[tableName] = data.sub_tables[tableName];
});
}
} catch (error: any) {
ElMessage.error(
error?.message || $t('workflow-designer.detail.loadFormDataFailed'),
);
} finally {
loading.value = false;
}
}
onMounted(() => {
loadData();
});
defineExpose({
reload: loadData,
});
</script>
<template>
<div class="form-tab">
<ElScrollbar class="bg-background h-full rounded-[8px]">
<!-- 骨架屏 -->
<div v-if="loading" class="form-skeleton">
<ElSkeleton animated>
<template #template>
<div class="skeleton-form">
<div v-for="i in 6" :key="i" class="skeleton-form-item">
<ElSkeletonItem
variant="text"
style="width: 80px; margin-bottom: 8px"
/>
<ElSkeletonItem
variant="rect"
style="width: 100%; height: 32px"
/>
</div>
</div>
</template>
</ElSkeleton>
</div>
<!-- 内容 -->
<div v-else-if="formItems.length > 0" class="form-content">
<ElForm
:model="formData"
:label-width="formConfig?.labelWidth || 100"
:label-position="formConfig?.labelPosition || 'right'"
:size="formConfig?.size || 'default'"
>
<PreviewItem
v-for="item in formItems"
:key="item.id"
:item="item"
:model-value="formData"
/>
</ElForm>
</div>
<ElEmpty
v-else
:description="$t('workflow-designer.detail.noFormContent')"
/>
</ElScrollbar>
</div>
</template>
<style scoped>
.form-tab {
height: 100%;
padding: 16px;
}
/* 骨架屏 */
.form-skeleton {
padding: 16px;
background: var(--el-bg-color);
border-radius: 8px;
}
.skeleton-form {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 24px;
}
.skeleton-form-item {
display: flex;
flex-direction: column;
}
/* 内容 */
.form-content {
padding: 16px;
background: var(--el-bg-color);
border-radius: 8px;
}
</style>
@@ -1,972 +0,0 @@
<script lang="ts" setup>
import type {
WorkflowInstance,
WorkflowLog,
WorkflowTaskListItem,
} from '#/api/online-dev/workflow';
/**
* 流程进度 Tab
*/
import { computed, onMounted, ref } from 'vue';
import { Clock, Users } from '@vben/icons';
import { $t } from '@vben/locales';
import {
ElEmpty,
ElImage,
ElMessage,
ElScrollbar,
ElSkeleton,
ElSkeletonItem,
ElTag,
ElTimeline,
ElTimelineItem,
} from 'element-plus';
import {
getInstanceDetailApi,
getInstanceLogsApi,
getInstancePendingTasksApi,
} from '#/api/online-dev/workflow';
import { UserAvatar } from '#/components/user-avatar/index';
import { getFileUrl } from '#/composables/useFileUrl';
const props = defineProps<Props>();
const emit = defineEmits<{
loaded: [instance: WorkflowInstance];
}>();
// 签名图片URL缓存
const signatureUrls = ref<Record<string, string>>({});
interface Props {
instanceId: string;
}
// 状态
const loading = ref(false);
const instance = ref<null | WorkflowInstance>(null);
const logs = ref<WorkflowLog[]>([]);
const pendingTasks = ref<WorkflowTaskListItem[]>([]);
// 按节点分组的待办任务
const groupedPendingTasks = computed(() => {
const groups: { nodeName: string; tasks: WorkflowTaskListItem[] }[] = [];
const nodeMap = new Map<string, WorkflowTaskListItem[]>();
for (const task of pendingTasks.value) {
const key = task.node_name || $t('workflow-designer.detail.unknownNode');
if (!nodeMap.has(key)) {
nodeMap.set(key, []);
}
nodeMap.get(key)!.push(task);
}
for (const [nodeName, tasks] of nodeMap) {
groups.push({ nodeName, tasks });
}
return groups;
});
// 状态配置
const statusConfig = computed(() => ({
pending: {
type: 'warning' as const,
label: $t('workflow-designer.detail.status.pending'),
},
approved: {
type: 'success' as const,
label: $t('workflow-designer.detail.status.approved'),
},
rejected: {
type: 'danger' as const,
label: $t('workflow-designer.detail.status.rejected'),
},
cancelled: {
type: 'info' as const,
label: $t('workflow-designer.detail.status.cancelled'),
},
canceled: {
type: 'info' as const,
label: $t('workflow-designer.detail.status.cancelled'),
},
}));
// 操作配置
const actionConfig = computed(() => ({
start: {
label: $t('workflow-designer.detail.actions.start'),
type: 'primary',
},
approve: {
label: $t('workflow-designer.detail.actions.approve'),
type: 'success',
},
reject: {
label: $t('workflow-designer.detail.actions.reject'),
type: 'danger',
},
return: {
label: $t('workflow-designer.detail.actions.return'),
type: 'warning',
},
transfer: {
label: $t('workflow-designer.detail.actions.transfer'),
type: 'warning',
},
delegate: {
label: $t('workflow-designer.detail.actions.delegate'),
type: 'warning',
},
add_sign: {
label: $t('workflow-designer.detail.actions.add_sign'),
type: 'warning',
},
revise: {
label: $t('workflow-designer.detail.actions.revise'),
type: 'primary',
},
cancel: {
label: $t('workflow-designer.detail.actions.cancel'),
type: 'info',
},
copy: { label: $t('workflow-designer.detail.actions.copy'), type: 'info' },
handle: {
label: $t('workflow-designer.detail.actions.handle'),
type: 'primary',
},
urge: { label: $t('workflow-designer.detail.actions.urge'), type: 'warning' },
task_timeout: {
label: $t('workflow-designer.detail.actions.task_timeout'),
type: 'danger',
},
task_timeout_notify: {
label: $t('workflow-designer.detail.actions.task_timeout_notify'),
type: 'warning',
},
task_auto_approve: {
label: $t('workflow-designer.detail.actions.task_auto_approve'),
type: 'success',
},
task_auto_reject: {
label: $t('workflow-designer.detail.actions.task_auto_reject'),
type: 'danger',
},
condition: {
label: $t('workflow-designer.detail.actions.condition'),
type: 'info',
},
delay_start: {
label: $t('workflow-designer.detail.actions.delay_start'),
type: 'info',
},
delay_complete: {
label: $t('workflow-designer.detail.actions.delay_complete'),
type: 'info',
},
delay_skip: {
label: $t('workflow-designer.detail.actions.delay_skip'),
type: 'warning',
},
notify: {
label: $t('workflow-designer.detail.actions.notify'),
type: 'info',
},
parallel_start: {
label: $t('workflow-designer.detail.actions.parallel_start'),
type: 'info',
},
parallel_complete: {
label: $t('workflow-designer.detail.actions.parallel_complete'),
type: 'info',
},
subflow_start: {
label: $t('workflow-designer.detail.actions.subflow_start'),
type: 'info',
},
subflow_complete: {
label: $t('workflow-designer.detail.actions.subflow_complete'),
type: 'info',
},
}));
// 加载数据
async function loadData() {
if (!props.instanceId) return;
loading.value = true;
try {
const [instanceRes, logsRes, pendingTasksRes] = await Promise.all([
getInstanceDetailApi(props.instanceId),
getInstanceLogsApi(props.instanceId),
getInstancePendingTasksApi(props.instanceId).catch(() => []),
]);
instance.value = instanceRes;
logs.value = logsRes || [];
pendingTasks.value = pendingTasksRes || [];
emit('loaded', instanceRes);
// 加载签名图片URL
await loadSignatureUrls(logsRes || []);
} catch (error: any) {
ElMessage.error(
error?.message || $t('workflow-designer.detail.loadDataFailed'),
);
} finally {
loading.value = false;
}
}
// 加载签名图片URL
async function loadSignatureUrls(logList: WorkflowLog[]) {
const fileIds = logList
.filter((log) => log.extra_data?.signature_file_id)
.map((log) => log.extra_data!.signature_file_id!);
if (fileIds.length === 0) return;
// 并行加载所有签名图片URL
const results = await Promise.all(
fileIds.map(async (fileId) => {
try {
const url = await getFileUrl(fileId);
return { fileId, url };
} catch {
return { fileId, url: '' };
}
}),
);
// 更新缓存
for (const { fileId, url } of results) {
if (url) {
signatureUrls.value[fileId] = url;
}
}
}
// 格式化时间
function formatTime(time: null | string | undefined) {
if (!time) return '-';
return time;
}
// 格式化剩余时间
function formatRemainingTime(timeoutAt: null | string | undefined) {
if (!timeoutAt) return '';
const timeout = new Date(timeoutAt).getTime();
const now = Date.now();
const diff = timeout - now;
if (diff <= 0) return $t('workflow-designer.detail.timeout');
const hours = Math.floor(diff / (1000 * 60 * 60));
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
if (hours > 24) {
const days = Math.floor(hours / 24);
return `${days}${$t('workflow-designer.detail.daysLater')}`;
}
if (hours > 0) return `${hours}${$t('workflow-designer.detail.hoursLater')}`;
return `${minutes}${$t('workflow-designer.detail.minutesLater')}`;
}
// 计算耗时
const duration = computed(() => {
if (!instance.value?.started_at) return '-';
const start = new Date(instance.value.started_at).getTime();
const end = instance.value.completed_at
? new Date(instance.value.completed_at).getTime()
: Date.now();
const diff = end - start;
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
if (days > 0)
return `${days}${$t('workflow-designer.detail.days')}${hours}${$t('workflow-designer.detail.hours')}`;
if (hours > 0)
return `${hours}${$t('workflow-designer.detail.hours')}${minutes}${$t('workflow-designer.detail.minutes')}`;
return `${minutes}${$t('workflow-designer.detail.minutes')}`;
});
onMounted(() => {
loadData();
});
// 暴露实例数据
defineExpose({
instance,
reload: loadData,
});
</script>
<template>
<div class="progress-tab">
<!-- 骨架屏 -->
<div v-if="loading" class="progress-skeleton">
<div class="skeleton-left">
<ElSkeleton animated>
<template #template>
<div class="skeleton-title">
<ElSkeletonItem variant="text" style="width: 100px" />
</div>
<div class="skeleton-card">
<div v-for="i in 20" :key="i" class="skeleton-row">
<ElSkeletonItem variant="text" style="width: 60px" />
<ElSkeletonItem variant="text" style="width: 150px" />
</div>
</div>
</template>
</ElSkeleton>
</div>
<div class="skeleton-right">
<ElSkeleton animated>
<template #template>
<div class="skeleton-title">
<ElSkeletonItem variant="text" style="width: 100px" />
</div>
<div class="skeleton-timeline">
<div v-for="i in 12" :key="i" class="skeleton-timeline-item">
<ElSkeletonItem
variant="circle"
style="width: 32px; height: 32px"
/>
<div class="skeleton-timeline-content">
<ElSkeletonItem variant="text" style="width: 200px" />
<ElSkeletonItem
variant="text"
style="width: 150px; margin-top: 8px"
/>
</div>
</div>
</div>
</template>
</ElSkeleton>
</div>
</div>
<!-- 内容 -->
<div v-else-if="instance" class="progress-layout">
<!-- 左侧流程信息 -->
<div class="info-panel">
<!-- 当前待办 -->
<template v-if="groupedPendingTasks.length > 0">
<div class="pending-section">
<div class="panel-title flex-shrink-0">
<Clock class="panel-icon" />
{{ $t('workflow-designer.detail.currentPending') }}
</div>
<ElScrollbar class="pending-scroll bg-background rounded-[8px]">
<div class="pending-groups">
<div
v-for="group in groupedPendingTasks"
:key="group.nodeName"
class="pending-group"
>
<div class="group-header">
<span class="group-node">{{ group.nodeName }}</span>
<ElTag
v-if="group.tasks.length > 1"
type="info"
size="small"
>
{{ group.tasks.length
}}{{ $t('workflow-designer.detail.person') }}
</ElTag>
</div>
<div class="group-users">
<div
v-for="task in group.tasks"
:key="task.id"
class="pending-user"
>
<UserAvatar
:user-id="task.assignee_id"
:name="task.assignee_name"
:size="32"
:font-size="12"
:show-info="true"
:shadow="false"
auto-load
/>
<ElTag
v-if="task.is_timeout"
type="danger"
size="small"
class="user-status"
>
{{ $t('workflow-designer.detail.timeout') }}
</ElTag>
<ElTag
v-else-if="task.timeout_at && !task.is_timeout"
type="warning"
size="small"
class="user-status"
:title="`超时时间: ${task.timeout_at}`"
>
{{ formatRemainingTime(task.timeout_at) }}
</ElTag>
<ElTag
v-else-if="task.status === 'waiting'"
type="info"
size="small"
class="user-status"
>
{{ $t('workflow-designer.detail.waiting') }}
</ElTag>
</div>
</div>
</div>
</div>
</ElScrollbar>
</div>
</template>
<!-- 流程信息 -->
<div class="flow-info-section">
<div class="panel-title flex-shrink-0">
<Users class="panel-icon" />
{{ $t('workflow-designer.detail.flowInfo') }}
</div>
<ElScrollbar class="flow-info-scroll bg-background rounded-[8px]">
<div class="info-card">
<div class="info-row">
<span class="info-label">{{
$t('workflow-designer.detail.flowTitle')
}}</span>
<span class="info-value title-value">{{ instance.title }}</span>
</div>
<div class="info-row">
<span class="info-label">{{
$t('workflow-designer.detail.flowType')
}}</span>
<span class="info-value">{{ instance.workflow_name }}</span>
</div>
<div class="info-row">
<span class="info-label">{{
$t('workflow-designer.detail.flowStatus')
}}</span>
<span class="info-value">
<ElTag
v-if="instance?.status && statusConfig[instance.status]"
:type="statusConfig[instance.status]?.type"
size="small"
>
{{ statusConfig[instance.status]?.label }}
</ElTag>
</span>
</div>
<div class="info-row">
<span class="info-label">{{
$t('workflow-designer.detail.flowNo')
}}</span>
<span class="info-value mono">{{ instance.instance_no }}</span>
</div>
<div class="info-row">
<span class="info-label">{{
$t('workflow-designer.detail.initiator')
}}</span>
<span class="info-value">
<UserAvatar
:user-id="instance.initiator_id"
:name="instance.initiator_name"
:size="28"
:font-size="12"
:show-info="true"
:shadow="false"
auto-load
/>
</span>
</div>
<div class="info-row">
<span class="info-label">{{
$t('workflow-designer.detail.currentNode')
}}</span>
<span class="info-value highlight">{{
instance.current_node_name || '-'
}}</span>
</div>
<div class="info-row">
<span class="info-label">{{
$t('workflow-designer.detail.startTime')
}}</span>
<span class="info-value">{{
formatTime(instance.started_at)
}}</span>
</div>
<div v-if="instance.completed_at" class="info-row">
<span class="info-label">{{
$t('workflow-designer.detail.completeTime')
}}</span>
<span class="info-value">{{
formatTime(instance.completed_at)
}}</span>
</div>
<div class="info-row">
<span class="info-label">{{
$t('workflow-designer.detail.duration')
}}</span>
<span class="info-value">
<Clock class="inline-icon" />
{{ duration }}
</span>
</div>
</div>
</ElScrollbar>
</div>
</div>
<!-- 右侧审批记录 -->
<div class="timeline-panel">
<div class="panel-title flex-shrink-0">
<Clock class="panel-icon" />
{{ $t('workflow-designer.detail.approvalRecord') }}
</div>
<ElScrollbar class="bg-background flex-1 rounded-[8px] px-4 pt-4">
<div class="timeline-content">
<ElTimeline v-if="logs.length > 0">
<ElTimelineItem
v-for="log in logs"
:key="log.id"
:type="(actionConfig[log.action]?.type || 'info') as any"
>
<template #dot>
<div
class="timeline-dot"
:class="actionConfig[log.action]?.type || 'info'"
>
<span class="dot-circle"></span>
<span class="dot-node-name">{{
log.node_name || $t('workflow-designer.detail.start')
}}</span>
</div>
</template>
<div class="log-card">
<div class="log-header">
<template v-if="log.operator_id">
<UserAvatar
:user-id="log.operator_id"
:name="log.operator_name"
:size="32"
:font-size="12"
:show-info="true"
:shadow="false"
auto-load
/>
</template>
<div v-else class="system-auto-label">
{{ $t('workflow-designer.detail.systemAuto') }}
</div>
<div class="log-action">
<ElTag
:type="
(actionConfig[log.action]?.type || 'info') as any
"
size="small"
>
{{ actionConfig[log.action]?.label || log.action }}
</ElTag>
<span class="log-time">{{
formatTime(log.sys_create_datetime)
}}</span>
</div>
</div>
<div v-if="log.comment" class="log-comment">
{{ log.comment }}
</div>
<!-- 签名图片 -->
<div
v-if="
log.extra_data?.signature_file_id &&
signatureUrls[log.extra_data.signature_file_id]
"
class="log-signature"
>
<span class="signature-label"
>{{ $t('workflow.pending.signature') }}:</span
>
<ElImage
:src="
signatureUrls[log.extra_data.signature_file_id] || ''
"
:preview-src-list="[
signatureUrls[log.extra_data.signature_file_id] || '',
]"
fit="contain"
class="signature-image"
/>
</div>
</div>
</ElTimelineItem>
</ElTimeline>
<ElEmpty
v-else
:description="$t('workflow-designer.detail.noApprovalRecord')"
/>
</div>
</ElScrollbar>
</div>
</div>
<ElEmpty v-else :description="$t('workflow-designer.detail.noData')" />
</div>
</template>
<style scoped>
.progress-tab {
height: 100%;
padding: 16px;
}
/* 骨架屏 */
.progress-skeleton {
display: flex;
gap: 16px;
height: 100%;
}
.skeleton-left {
flex-shrink: 0;
width: 360px;
}
.skeleton-right {
flex: 1;
}
.skeleton-title {
padding-bottom: 12px;
margin-bottom: 16px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.skeleton-card {
padding: 16px;
background: var(--el-bg-color);
border-radius: 8px;
}
.skeleton-row {
display: flex;
gap: 16px;
padding: 10px 0;
border-bottom: 1px dashed var(--el-border-color-lighter);
}
.skeleton-row:last-child {
border-bottom: none;
}
.skeleton-timeline {
padding: 16px;
background: var(--el-bg-color);
border-radius: 8px;
}
.skeleton-timeline-item {
display: flex;
gap: 12px;
margin-bottom: 24px;
}
.skeleton-timeline-content {
flex: 1;
}
/* 内容布局 */
.progress-layout {
display: flex;
gap: 16px;
height: 100%;
}
.info-panel {
display: flex;
flex-shrink: 0;
flex-direction: column;
gap: 16px;
width: 360px;
}
/* 当前待办区域 */
.pending-section {
display: flex;
flex-direction: column;
flex-shrink: 0;
max-height: 300px;
}
.pending-scroll {
flex: 1;
min-height: 0;
}
/* 流程信息区域 */
.flow-info-section {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
}
.flow-info-scroll {
flex: 1;
min-height: 0;
}
.timeline-panel {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
}
.panel-title {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 12px;
font-size: 14px;
font-weight: 500;
color: var(--el-text-color-primary);
}
.panel-icon {
width: 18px;
height: 18px;
color: var(--el-color-primary);
}
/* 流程信息卡片 */
.info-card {
flex: 1;
padding: 16px;
background: var(--el-bg-color);
border-radius: 8px;
}
.info-row {
display: flex;
align-items: flex-start;
padding: 10px 0;
border-bottom: 1px dashed var(--el-border-color-lighter);
}
.info-row:last-child {
border-bottom: none;
}
.info-label {
flex-shrink: 0;
width: 80px;
font-size: 13px;
color: var(--el-text-color-secondary);
}
.info-value {
display: flex;
flex: 1;
align-items: center;
font-size: 13px;
color: var(--el-text-color-primary);
}
.info-value.title-value {
font-weight: 500;
}
.info-value.mono {
font-family: monospace;
font-size: 12px;
}
.info-value.highlight {
font-weight: 500;
color: var(--el-color-primary);
}
.inline-icon {
width: 14px;
height: 14px;
margin-right: 4px;
color: var(--el-text-color-secondary);
}
/* 审批记录时间线 */
.timeline-content {
flex: 1;
padding: 16px;
overflow: auto;
border-radius: 8px;
}
/* 时间线圆点 */
.timeline-dot {
display: flex;
gap: 6px;
align-items: center;
white-space: nowrap;
}
.dot-circle {
flex-shrink: 0;
width: 10px;
height: 10px;
border-radius: 50%;
}
.timeline-dot.primary .dot-circle {
background: var(--el-color-primary);
}
.timeline-dot.success .dot-circle {
background: var(--el-color-success);
}
.timeline-dot.danger .dot-circle {
background: var(--el-color-danger);
}
.timeline-dot.warning .dot-circle {
background: var(--el-color-warning);
}
.timeline-dot.info .dot-circle {
background: var(--el-color-info);
}
.dot-node-name {
font-size: 12px;
font-weight: 500;
color: var(--el-text-color-primary);
}
.log-card {
padding: 14px 14px;
margin-left: 60px;
background: var(--el-bg-color);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
border-radius: 6px;
}
.log-header {
display: flex;
gap: 12px;
align-items: flex-start;
}
.system-auto-label {
display: flex;
align-items: center;
height: 32px;
padding: 0 8px;
font-size: 12px;
color: var(--el-text-color-secondary);
background: var(--el-fill-color-light);
border-radius: 4px;
}
.log-action {
display: flex;
flex-direction: column;
gap: 4px;
}
.log-time {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.log-comment {
padding: 8px 10px;
margin-top: 8px;
font-size: 12px;
line-height: 1.5;
color: var(--el-text-color-regular);
background: var(--el-fill-color-lighter);
border-radius: 4px;
}
/* 签名图片 */
.log-signature {
display: flex;
gap: 8px;
align-items: flex-start;
margin-top: 8px;
padding: 8px;
background: var(--el-fill-color-lighter);
border-radius: 4px;
}
.signature-label {
flex-shrink: 0;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.signature-image {
max-width: 120px;
max-height: 60px;
border-radius: 4px;
background: #fff;
}
/* 当前待办 */
.pending-groups {
display: flex;
flex-direction: column;
gap: 12px;
padding: 8px;
background: var(--el-bg-color);
border-radius: 8px;
}
.pending-group {
padding: 10px 12px;
background: var(--el-bg-color);
border-radius: 6px;
}
.group-header {
display: flex;
gap: 8px;
align-items: center;
padding-bottom: 8px;
margin-bottom: 10px;
border-bottom: 1px dashed var(--el-border-color-lighter);
}
.group-node {
font-size: 13px;
font-weight: 500;
color: var(--el-text-color-primary);
}
.group-users {
display: flex;
flex-wrap: wrap;
gap: 16px;
}
.pending-user {
position: relative;
display: flex;
flex-direction: column;
gap: 4px;
align-items: center;
}
.user-status {
position: absolute;
top: -4px;
right: -8px;
transform: scale(0.8);
}
</style>
@@ -1,2 +0,0 @@
export { default as DocumentPreviewDialog } from './DocumentPreviewDialog.vue';
export { default as InstanceDetailPanel } from './detial/InstanceDetailPanel.vue';
@@ -1,140 +0,0 @@
<script setup lang="ts">
import { ref, onMounted, watch, nextTick } from 'vue';
import type { DrawData, DrawElement, AppState, BinaryFiles } from './types';
import type { NormalizedZoomValue } from './types';
import { getCommonBounds } from './elements/bounds';
import { renderStaticScene } from './core/renderer/static-scene';
import { loadDrawFonts } from './fonts';
const props = withDefaults(
defineProps<{
data: DrawData;
width: number;
height: number;
}>(),
{
width: 800,
height: 450,
},
);
const canvasRef = ref<HTMLCanvasElement>();
const containerRef = ref<HTMLDivElement>();
const imageCache = new Map<string, HTMLImageElement>();
function buildImageCache(files: BinaryFiles): Promise<void> {
const promises: Promise<void>[] = [];
for (const [id, file] of Object.entries(files)) {
if (imageCache.has(id)) continue;
promises.push(
new Promise<void>((resolve) => {
const img = new Image();
img.onload = () => {
imageCache.set(id, img);
resolve();
};
img.onerror = () => resolve();
img.src = file.dataURL;
}),
);
}
return Promise.all(promises).then(() => {});
}
function render() {
const canvas = canvasRef.value;
if (!canvas || !props.data) return;
const elements = (props.data.elements || []).filter(
(el) => !el.isDeleted,
) as DrawElement[];
if (elements.length === 0) return;
const [x1, y1, x2, y2] = getCommonBounds(elements);
const contentWidth = x2 - x1;
const contentHeight = y2 - y1;
if (contentWidth <= 0 || contentHeight <= 0) return;
const displayWidth = props.width;
const displayHeight = props.height;
const padding = 20;
const zoom = Math.min(
displayWidth / (contentWidth + padding * 2),
displayHeight / (contentHeight + padding * 2),
);
const scrollX = -x1 + padding + (displayWidth / zoom - contentWidth - padding * 2) / 2;
const scrollY = -y1 + padding + (displayHeight / zoom - contentHeight - padding * 2) / 2;
const appState: AppState = {
viewBackgroundColor:
props.data.appState?.viewBackgroundColor || 'transparent',
zoom: { value: zoom as NormalizedZoomValue },
scrollX,
scrollY,
width: displayWidth,
height: displayHeight,
offsetTop: 0,
offsetLeft: 0,
theme: props.data.appState?.theme || 'light',
gridModeEnabled: false,
gridSize: 20,
gridStep: 5,
frameRendering: { enabled: true, name: true, outline: true, clip: true },
exportBackground: true,
exportScale: 2,
exportWithDarkMode: false,
} as AppState;
renderStaticScene(
canvas,
elements,
appState,
props.data.files || {},
imageCache,
);
}
async function doRender() {
if (props.data?.files) {
await buildImageCache(props.data.files);
}
await nextTick();
render();
}
onMounted(async () => {
await loadDrawFonts();
await doRender();
});
watch(
() => [props.data, props.width, props.height],
() => doRender(),
{ deep: true },
);
defineExpose({ render: doRender });
</script>
<template>
<div
ref="containerRef"
class="draw-preview"
:style="{ width: `${width}px`, height: `${height}px` }"
>
<canvas ref="canvasRef" class="draw-preview__canvas" />
</div>
</template>
<style scoped>
.draw-preview {
overflow: hidden;
}
.draw-preview__canvas {
display: block;
}
</style>
@@ -1,397 +0,0 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import type { DrawData, DrawProps } from './types';
import { useDrawStore } from './store/draw-store';
import { useDrawEngine } from './composables/use-draw-engine';
import { useKeyboard } from './composables/use-keyboard';
import DrawCanvas from './components/DrawCanvas.vue';
import DrawToolbar from './components/DrawToolbar.vue';
import ZoomControls from './components/ZoomControls.vue';
import UndoRedoControls from './components/UndoRedoControls.vue';
import TextEditor from './components/TextEditor.vue';
import PropertyPanel from './components/PropertyPanel.vue';
import ContextMenu from './components/ContextMenu.vue';
import HyperlinkPopup from './components/HyperlinkPopup.vue';
import StatsPanel from './components/StatsPanel.vue';
import MainMenu from './components/MainMenu.vue';
import { $t } from '@vben/locales';
import { Magnet, BarChart, Minimize2 } from '@vben/icons';
import { exportToBlob } from './data/export-canvas';
import { exportToSvgString } from './data/export-svg';
import { serializeAsJSON } from './data/json';
import { loadDrawFonts } from './fonts';
const contextMenuRef = ref<InstanceType<typeof ContextMenu> | null>(null);
function onContextMenu(e: MouseEvent) {
e.preventDefault();
contextMenuRef.value?.show(e);
}
const props = withDefaults(defineProps<DrawProps>(), {
readonly: false,
width: '100%',
height: '100%',
});
const emit = defineEmits<{
(e: 'update:modelValue', data: DrawData): void;
(e: 'change', elements: any[], appState: any): void;
(e: 'save', data: DrawData): void;
}>();
const store = useDrawStore();
useDrawEngine({
initialData: props.modelValue ?? null,
readonly: props.readonly,
});
useKeyboard();
loadDrawFonts().then(() => {
store.requestRender();
});
// sync props
watch(
() => props.theme,
(val) => {
if (val) store.theme = val;
},
{ immediate: true },
);
watch(
() => props.gridMode,
(val) => {
if (val !== undefined) store.gridModeEnabled = val;
},
{ immediate: true },
);
watch(
() => props.zenMode,
(val) => {
if (val !== undefined) store.zenModeEnabled = val;
},
{ immediate: true },
);
watch(
() => props.readonly,
(val) => {
store.viewModeEnabled = !!val;
store.requestRender();
},
);
// watch model value changes from outside
watch(
() => props.modelValue,
(val) => {
if (val) {
store.loadDrawData(val);
}
},
);
// emit changes
watch(
() => store.sceneVersion,
() => {
const data = store.getDrawData();
emit('update:modelValue', data);
emit('change', [...data.elements], data.appState);
},
);
const containerStyle = ref({
width: typeof props.width === 'number' ? `${props.width}px` : props.width,
height: typeof props.height === 'number' ? `${props.height}px` : props.height,
});
// Exposed API
function getJSON(): string {
return serializeAsJSON(
store.scene.getElements(),
store.getAppState(),
store.files,
);
}
async function getThumbnail(
opts: { scale?: number; maxWidth?: number } = {},
): Promise<string> {
const blob = await exportToBlob(
store.scene.getNonDeletedElements(),
store.getAppState(),
store.files,
store.imageCache,
{ scale: opts.scale ?? 1 },
);
return URL.createObjectURL(blob);
}
function save(): void {
const data = store.getDrawData();
emit('save', data);
}
function getElements() {
return store.scene.getNonDeletedElements();
}
function getDrawData(): DrawData {
return store.getDrawData();
}
async function exportToPng(
opts: { background?: boolean; scale?: number; darkMode?: boolean } = {},
): Promise<Blob> {
const appState = store.getAppState();
const background = opts.background ?? appState.exportBackground;
const scale = opts.scale ?? appState.exportScale;
if (opts.darkMode ?? appState.exportWithDarkMode) {
appState.viewBackgroundColor = '#121212';
}
return exportToBlob(
store.scene.getNonDeletedElements(),
appState,
store.files,
store.imageCache,
{ background, scale },
);
}
function exportToSvg(
opts: { background?: boolean } = {},
): string {
const appState = store.getAppState();
const background = opts.background ?? appState.exportBackground;
return exportToSvgString(
store.scene.getNonDeletedElements(),
appState,
store.files,
{ background },
);
}
async function copyAsPng(
opts: { background?: boolean; scale?: number } = {},
): Promise<void> {
const blob = await exportToPng(opts);
await navigator.clipboard.write([
new ClipboardItem({ 'image/png': blob }),
]);
}
async function copyAsSvg(
opts: { background?: boolean } = {},
): Promise<void> {
const svgStr = exportToSvg(opts);
await navigator.clipboard.writeText(svgStr);
}
defineExpose({
getJSON,
getThumbnail,
save,
getElements,
getDrawData,
exportToPng,
exportToSvg,
copyAsPng,
copyAsSvg,
store,
});
</script>
<template>
<div class="zq-draw" :style="containerStyle" @contextmenu="onContextMenu">
<DrawCanvas />
<TextEditor />
<PropertyPanel
:class="{ 'zq-draw-zen-hidden': store.zenModeEnabled }"
/>
<HyperlinkPopup />
<ContextMenu ref="contextMenuRef" />
<StatsPanel
:class="{ 'zq-draw-zen-hidden': store.zenModeEnabled }"
/>
<template v-if="!props.readonly">
<div
class="zq-draw-top-toolbar"
:class="{ 'zq-draw-zen-hidden': store.zenModeEnabled }"
>
<DrawToolbar />
</div>
<div
class="zq-draw-top-left"
:class="{ 'zq-draw-zen-hidden': store.zenModeEnabled }"
>
<MainMenu />
</div>
<div
class="zq-draw-bottom-left"
:class="{ 'zq-draw-zen-hidden': store.zenModeEnabled }"
>
<UndoRedoControls />
<ZoomControls />
<div class="zq-draw-toggle-group">
<button
class="zq-draw-toggle-btn"
:class="{ 'is-active': store.objectsSnapModeEnabled }"
:title="$t('draw.action.toggleSnap') + ' (Alt+S)'"
@click="store.objectsSnapModeEnabled = !store.objectsSnapModeEnabled"
>
<Magnet class="zq-draw-toggle-icon" />
</button>
<button
class="zq-draw-toggle-btn"
:class="{ 'is-active': store.showStats }"
:title="$t('draw.action.toggleStats') + ' (Alt+I)'"
@click="store.showStats = !store.showStats"
>
<BarChart class="zq-draw-toggle-icon" />
</button>
</div>
</div>
</template>
<template v-else>
<div class="zq-draw-bottom-left">
<ZoomControls />
</div>
</template>
<!-- Zen Mode exit hint -->
<div
v-if="store.zenModeEnabled && !props.readonly"
class="zq-draw-zen-exit"
:title="$t('draw.action.toggleZenMode') + ' (Alt+Z)'"
@click="store.zenModeEnabled = false"
>
<Minimize2 class="h-4 w-4" />
</div>
</div>
</template>
<style scoped lang="scss">
.zq-draw {
position: relative;
overflow: hidden;
background: var(--el-bg-color);
}
.zq-draw-top-toolbar {
position: absolute;
top: 12px;
left: 50%;
transform: translateX(-50%);
z-index: 10;
}
.zq-draw-top-left {
position: absolute;
top: 12px;
left: 12px;
z-index: 10;
}
.zq-draw-bottom-left {
position: absolute;
bottom: 12px;
left: 12px;
z-index: 10;
display: flex;
gap: 8px;
align-items: flex-end;
}
.zq-draw-toggle-group {
display: flex;
align-items: center;
gap: 2px;
padding: 4px;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-light);
border-radius: 8px;
box-shadow: 0 2px 8px rgb(0 0 0 / 8%);
}
.zq-draw-toggle-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: 6px;
background: transparent;
cursor: pointer;
color: var(--el-text-color-regular);
transition: all 0.15s;
&:hover {
background: var(--el-fill-color-light);
}
&.is-active {
background: var(--el-color-primary-light-9);
color: var(--el-color-primary);
}
}
.zq-draw-toggle-icon {
width: 16px;
height: 16px;
}
.zq-draw-zen-hidden {
opacity: 0;
pointer-events: none;
transform: translateY(8px);
transition:
opacity 0.25s ease,
transform 0.25s ease;
}
.zq-draw-top-toolbar,
.zq-draw-top-left,
.zq-draw-bottom-left,
.zq-draw-property-panel {
transition:
opacity 0.25s ease,
transform 0.25s ease;
}
.zq-draw-zen-exit {
position: absolute;
bottom: 12px;
left: 50%;
transform: translateX(-50%);
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 8px;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-light);
cursor: pointer;
color: var(--el-text-color-regular);
opacity: 0.15;
transition: opacity 0.2s ease;
&:hover {
opacity: 1;
box-shadow: 0 2px 8px rgb(0 0 0 / 12%);
}
}
</style>
@@ -1,436 +0,0 @@
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
import { useI18n } from '@vben/locales';
import {
Copy,
Scissors,
Clipboard,
Trash2,
Layers,
Lock,
Unlock,
Group,
Ungroup,
FlipHorizontal2,
FlipVertical2,
Link,
ChevronRight,
AlignLeft,
Paintbrush,
} from '@vben/icons';
import { useDrawStore } from '../store/draw-store';
const { t } = useI18n();
const store = useDrawStore();
const visible = ref(false);
const menuX = ref(0);
const menuY = ref(0);
const expandedSub = ref<string | null>(null);
const hasSelection = computed(() => store.selectedElements.length > 0);
const isMultiSelect = computed(() => store.selectedElements.length > 1);
const isLocked = computed(() => store.isSelectedLocked);
const hasGroup = computed(() => {
return store.selectedElements.some((el) => el.groupIds.length > 0);
});
function show(e: MouseEvent) {
e.preventDefault();
e.stopPropagation();
visible.value = true;
menuX.value = e.clientX;
menuY.value = e.clientY;
expandedSub.value = null;
}
function hide() {
visible.value = false;
expandedSub.value = null;
}
function handleAction(action: string) {
hide();
switch (action) {
case 'copy':
store.copySelectedElements();
break;
case 'cut':
store.cutSelectedElements();
break;
case 'paste':
store.pasteFromClipboard();
break;
case 'duplicate':
store.duplicateSelectedElements();
break;
case 'delete':
store.deleteSelectedElementsWithBindings();
break;
case 'selectAll':
store.selectAll();
break;
case 'bringToFront':
store.bringToFront();
break;
case 'sendToBack':
store.sendToBack();
break;
case 'bringForward':
store.bringForward();
break;
case 'sendBackward':
store.sendBackward();
break;
case 'lock':
store.lockSelected();
break;
case 'unlock':
store.unlockSelected();
break;
case 'group':
store.groupSelected();
break;
case 'ungroup':
store.ungroupSelected();
break;
case 'flipH':
store.flipSelectedHorizontal();
break;
case 'flipV':
store.flipSelectedVertical();
break;
case 'addLink':
store.showHyperlinkPopup = 'editor';
break;
case 'alignLeft':
store.alignSelected({ position: 'start', axis: 'x' });
break;
case 'alignCenter':
store.alignSelected({ position: 'center', axis: 'x' });
break;
case 'alignRight':
store.alignSelected({ position: 'end', axis: 'x' });
break;
case 'alignTop':
store.alignSelected({ position: 'start', axis: 'y' });
break;
case 'alignMiddle':
store.alignSelected({ position: 'center', axis: 'y' });
break;
case 'alignBottom':
store.alignSelected({ position: 'end', axis: 'y' });
break;
case 'distributeH':
store.distributeSelected({ space: 'between', axis: 'x' });
break;
case 'distributeV':
store.distributeSelected({ space: 'between', axis: 'y' });
break;
case 'copyStyle':
store.copyStyle();
break;
case 'pasteStyle':
store.pasteStyle();
break;
case 'toggleGrid':
store.gridModeEnabled = !store.gridModeEnabled;
store.requestRender();
break;
}
}
function onDocumentClick() {
if (visible.value) hide();
}
onMounted(() => {
document.addEventListener('click', onDocumentClick);
});
onBeforeUnmount(() => {
document.removeEventListener('click', onDocumentClick);
});
defineExpose({ show });
</script>
<template>
<Teleport to="body">
<div
v-if="visible"
class="fixed z-[9999] min-w-48 rounded-lg border border-border bg-popover py-1 text-sm text-popover-foreground shadow-lg"
:style="{ left: `${menuX}px`, top: `${menuY}px` }"
@click.stop
@contextmenu.prevent
>
<!-- With selection -->
<template v-if="hasSelection">
<button class="menu-item" @click="handleAction('copy')">
<Copy class="menu-icon" />
{{ t('draw.menu.copy') }}
<span class="menu-shortcut">Ctrl+C</span>
</button>
<button class="menu-item" @click="handleAction('cut')">
<Scissors class="menu-icon" />
{{ t('draw.menu.cut') }}
<span class="menu-shortcut">Ctrl+X</span>
</button>
<button class="menu-item" @click="handleAction('paste')">
<Clipboard class="menu-icon" />
{{ t('draw.menu.paste') }}
<span class="menu-shortcut">Ctrl+V</span>
</button>
<button class="menu-item" @click="handleAction('duplicate')">
<Copy class="menu-icon" />
{{ t('draw.menu.duplicate') }}
<span class="menu-shortcut">Ctrl+D</span>
</button>
<div class="menu-separator" />
<button class="menu-item" @click="handleAction('copyStyle')">
<Paintbrush class="menu-icon" />
{{ t('draw.menu.copyStyle') }}
<span class="menu-shortcut">Ctrl+Alt+C</span>
</button>
<button
class="menu-item"
:class="{ 'opacity-50 pointer-events-none': !store.copiedStyle }"
@click="handleAction('pasteStyle')"
>
<Paintbrush class="menu-icon" />
{{ t('draw.menu.pasteStyle') }}
<span class="menu-shortcut">Ctrl+Alt+V</span>
</button>
<div class="menu-separator" />
<!-- Layer submenu -->
<div
class="menu-item-sub"
@mouseenter="expandedSub = 'layer'"
@mouseleave="expandedSub = null"
>
<button class="menu-item w-full">
<Layers class="menu-icon" />
{{ t('draw.menu.layer') }}
<ChevronRight class="ml-auto h-3.5 w-3.5" />
</button>
<div v-if="expandedSub === 'layer'" class="submenu">
<button class="menu-item" @click="handleAction('bringToFront')">
{{ t('draw.menu.bringToFront') }}
<span class="menu-shortcut">Ctrl+Shift+]</span>
</button>
<button class="menu-item" @click="handleAction('bringForward')">
{{ t('draw.menu.bringForward') }}
<span class="menu-shortcut">Ctrl+]</span>
</button>
<button class="menu-item" @click="handleAction('sendBackward')">
{{ t('draw.menu.sendBackward') }}
<span class="menu-shortcut">Ctrl+[</span>
</button>
<button class="menu-item" @click="handleAction('sendToBack')">
{{ t('draw.menu.sendToBack') }}
<span class="menu-shortcut">Ctrl+Shift+[</span>
</button>
</div>
</div>
<!-- Flip submenu -->
<div
class="menu-item-sub"
@mouseenter="expandedSub = 'flip'"
@mouseleave="expandedSub = null"
>
<button class="menu-item w-full">
<FlipHorizontal2 class="menu-icon" />
{{ t('draw.menu.flip') }}
<ChevronRight class="ml-auto h-3.5 w-3.5" />
</button>
<div v-if="expandedSub === 'flip'" class="submenu">
<button class="menu-item" @click="handleAction('flipH')">
<FlipHorizontal2 class="menu-icon" />
{{ t('draw.menu.flipH') }}
</button>
<button class="menu-item" @click="handleAction('flipV')">
<FlipVertical2 class="menu-icon" />
{{ t('draw.menu.flipV') }}
</button>
</div>
</div>
<!-- Align submenu (multi-select only) -->
<div
v-if="isMultiSelect"
class="menu-item-sub"
@mouseenter="expandedSub = 'align'"
@mouseleave="expandedSub = null"
>
<button class="menu-item w-full">
<AlignLeft class="menu-icon" />
{{ t('draw.menu.align') }}
<ChevronRight class="ml-auto h-3.5 w-3.5" />
</button>
<div v-if="expandedSub === 'align'" class="submenu">
<button class="menu-item" @click="handleAction('alignLeft')">
{{ t('draw.menu.alignLeft') }}
</button>
<button class="menu-item" @click="handleAction('alignCenter')">
{{ t('draw.menu.alignCenter') }}
</button>
<button class="menu-item" @click="handleAction('alignRight')">
{{ t('draw.menu.alignRight') }}
</button>
<div class="menu-separator" />
<button class="menu-item" @click="handleAction('alignTop')">
{{ t('draw.menu.alignTop') }}
</button>
<button class="menu-item" @click="handleAction('alignMiddle')">
{{ t('draw.menu.alignMiddle') }}
</button>
<button class="menu-item" @click="handleAction('alignBottom')">
{{ t('draw.menu.alignBottom') }}
</button>
<div class="menu-separator" />
<button class="menu-item" @click="handleAction('distributeH')">
{{ t('draw.menu.distributeH') }}
</button>
<button class="menu-item" @click="handleAction('distributeV')">
{{ t('draw.menu.distributeV') }}
</button>
</div>
</div>
<div class="menu-separator" />
<!-- Group / Ungroup -->
<button
v-if="isMultiSelect"
class="menu-item"
@click="handleAction('group')"
>
<Group class="menu-icon" />
{{ t('draw.menu.group') }}
<span class="menu-shortcut">Ctrl+G</span>
</button>
<button
v-if="hasGroup"
class="menu-item"
@click="handleAction('ungroup')"
>
<Ungroup class="menu-icon" />
{{ t('draw.menu.ungroup') }}
<span class="menu-shortcut">Ctrl+Shift+G</span>
</button>
<!-- Lock / Unlock -->
<button
v-if="!isLocked"
class="menu-item"
@click="handleAction('lock')"
>
<Lock class="menu-icon" />
{{ t('draw.menu.lock') }}
<span class="menu-shortcut">Ctrl+L</span>
</button>
<button
v-if="isLocked"
class="menu-item"
@click="handleAction('unlock')"
>
<Unlock class="menu-icon" />
{{ t('draw.menu.unlock') }}
</button>
<!-- Hyperlink -->
<button class="menu-item" @click="handleAction('addLink')">
<Link class="menu-icon" />
{{ t('draw.menu.addLink') }}
<span class="menu-shortcut">Ctrl+K</span>
</button>
<div class="menu-separator" />
<button class="menu-item text-destructive" @click="handleAction('delete')">
<Trash2 class="menu-icon" />
{{ t('draw.menu.delete') }}
<span class="menu-shortcut">Del</span>
</button>
</template>
<!-- No selection (canvas context menu) -->
<template v-else>
<button class="menu-item" @click="handleAction('paste')">
<Clipboard class="menu-icon" />
{{ t('draw.menu.paste') }}
<span class="menu-shortcut">Ctrl+V</span>
</button>
<button class="menu-item" @click="handleAction('selectAll')">
{{ t('draw.menu.selectAll') }}
<span class="menu-shortcut">Ctrl+A</span>
</button>
<div class="menu-separator" />
<button class="menu-item" @click="handleAction('toggleGrid')">
{{ t('draw.menu.toggleGrid') }}
</button>
</template>
</div>
</Teleport>
</template>
<style scoped>
.menu-item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 6px 12px;
text-align: left;
cursor: pointer;
border: none;
background: none;
font-size: 13px;
white-space: nowrap;
}
.menu-item:hover {
background-color: hsl(var(--accent));
}
.menu-icon {
width: 14px;
height: 14px;
flex-shrink: 0;
}
.menu-shortcut {
margin-left: auto;
font-size: 11px;
opacity: 0.5;
}
.menu-separator {
height: 1px;
margin: 4px 0;
background-color: hsl(var(--border));
}
.menu-item-sub {
position: relative;
}
.submenu {
position: absolute;
left: 100%;
top: 0;
min-width: 180px;
border-radius: 8px;
border: 1px solid hsl(var(--border));
background-color: hsl(var(--popover));
padding: 4px 0;
box-shadow: 0 4px 12px rgb(0 0 0 / 10%);
z-index: 1;
}
</style>
@@ -1,65 +0,0 @@
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, watch } from 'vue';
import { useDrawStore } from '../store/draw-store';
import { useCanvasResize } from '../composables/use-canvas-resize';
import { useScrollWheel } from '../composables/use-scroll-wheel';
import { usePointerEvents } from '../composables/use-pointer-events';
const containerRef = ref<HTMLElement | null>(null);
const staticCanvasRef = ref<HTMLCanvasElement | null>(null);
const interactiveCanvasRef = ref<HTMLCanvasElement | null>(null);
const store = useDrawStore();
useCanvasResize(containerRef);
useScrollWheel(containerRef);
const pointer = usePointerEvents(containerRef);
onMounted(() => {
store.staticCanvas = staticCanvasRef.value;
store.interactiveCanvas = interactiveCanvasRef.value;
pointer.attach();
store.requestRender();
});
onBeforeUnmount(() => {
pointer.detach();
});
watch(
() => store.sceneVersion,
() => {
store.requestRender();
},
);
</script>
<template>
<div ref="containerRef" class="zq-draw-canvas-container">
<canvas ref="staticCanvasRef" class="zq-draw-static-canvas" />
<canvas ref="interactiveCanvasRef" class="zq-draw-interactive-canvas" />
</div>
</template>
<style scoped lang="scss">
.zq-draw-canvas-container {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
touch-action: none;
}
.zq-draw-static-canvas,
.zq-draw-interactive-canvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.zq-draw-interactive-canvas {
z-index: 1;
}
</style>
@@ -1,104 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue';
import { $t } from '@vben/locales';
import {
MousePointer2,
Hand,
Square,
Circle,
Diamond,
Minus,
MoveRight,
Pencil,
Type,
Image,
Eraser,
Frame,
} from '@vben/icons';
import { useDrawStore } from '../store/draw-store';
import type { ToolType } from '../types';
const store = useDrawStore();
interface ToolItem {
type: ToolType;
icon: any;
labelKey: string;
shortcut: string;
}
const tools: ToolItem[] = [
{ type: 'selection', icon: MousePointer2, labelKey: 'draw.tool.selection', shortcut: 'V' },
{ type: 'hand', icon: Hand, labelKey: 'draw.tool.hand', shortcut: 'H' },
{ type: 'rectangle', icon: Square, labelKey: 'draw.tool.rectangle', shortcut: 'R' },
{ type: 'ellipse', icon: Circle, labelKey: 'draw.tool.ellipse', shortcut: 'O' },
{ type: 'diamond', icon: Diamond, labelKey: 'draw.tool.diamond', shortcut: 'D' },
{ type: 'line', icon: Minus, labelKey: 'draw.tool.line', shortcut: 'L' },
{ type: 'arrow', icon: MoveRight, labelKey: 'draw.tool.arrow', shortcut: 'A' },
{ type: 'freedraw', icon: Pencil, labelKey: 'draw.tool.freedraw', shortcut: 'P' },
{ type: 'text', icon: Type, labelKey: 'draw.tool.text', shortcut: 'T' },
{ type: 'image', icon: Image, labelKey: 'draw.tool.image', shortcut: 'I' },
{ type: 'eraser', icon: Eraser, labelKey: 'draw.tool.eraser', shortcut: 'E' },
{ type: 'frame', icon: Frame, labelKey: 'draw.tool.frame', shortcut: 'F' },
];
const currentTool = computed(() => store.activeTool.type);
function selectTool(type: ToolType) {
store.setActiveTool(type);
}
</script>
<template>
<div class="zq-draw-toolbar">
<div
v-for="tool in tools"
:key="tool.type"
class="zq-draw-toolbar-item"
:class="{ 'is-active': currentTool === tool.type }"
:title="`${$t(tool.labelKey)} (${tool.shortcut})`"
@click="selectTool(tool.type)"
>
<component :is="tool.icon" class="zq-draw-toolbar-icon" />
</div>
</div>
</template>
<style scoped lang="scss">
.zq-draw-toolbar {
display: flex;
align-items: center;
gap: 2px;
padding: 4px 8px;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-light);
border-radius: 10px;
box-shadow: 0 2px 8px rgb(0 0 0 / 8%);
}
.zq-draw-toolbar-item {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 8px;
cursor: pointer;
color: var(--el-text-color-regular);
transition: all 0.15s;
&:hover {
background: var(--el-fill-color-light);
}
&.is-active {
background: var(--el-color-primary-light-9);
color: var(--el-color-primary);
}
}
.zq-draw-toolbar-icon {
width: 18px;
height: 18px;
}
</style>
@@ -1,141 +0,0 @@
<script setup lang="ts">
import { ref, computed, watch, nextTick } from 'vue';
import { useI18n } from '@vben/locales';
import { ElInput } from 'element-plus';
import { Link, ExternalLink, Trash2, X } from '@vben/icons';
import { useDrawStore } from '../store/draw-store';
const { t } = useI18n();
const store = useDrawStore();
const linkValue = ref('');
const inputRef = ref<InstanceType<typeof ElInput> | null>(null);
const isVisible = computed(() => store.showHyperlinkPopup !== false);
const isEditor = computed(() => store.showHyperlinkPopup === 'editor');
const isInfo = computed(() => store.showHyperlinkPopup === 'info');
const selectedElement = computed(() => {
if (store.selectedElements.length !== 1) return null;
return store.selectedElements[0]!;
});
const currentLink = computed(() => selectedElement.value?.link || '');
watch(
() => store.showHyperlinkPopup,
(val) => {
if (val === 'editor') {
linkValue.value = currentLink.value;
nextTick(() => {
inputRef.value?.focus();
});
}
},
);
function saveLink() {
if (!selectedElement.value) return;
const link = linkValue.value.trim() || null;
store.setElementLink(selectedElement.value.id, link);
store.showHyperlinkPopup = link ? 'info' : false;
}
function removeLink() {
if (!selectedElement.value) return;
store.setElementLink(selectedElement.value.id, null);
store.showHyperlinkPopup = false;
}
function openLink() {
if (currentLink.value) {
window.open(currentLink.value, '_blank', 'noopener,noreferrer');
}
}
function editLink() {
store.showHyperlinkPopup = 'editor';
}
function close() {
store.showHyperlinkPopup = false;
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault();
saveLink();
}
if (e.key === 'Escape') {
e.preventDefault();
close();
}
}
</script>
<template>
<div
v-if="isVisible && selectedElement"
class="absolute left-1/2 top-2 z-30 -translate-x-1/2 rounded-lg border border-border bg-card p-3 shadow-lg"
@click.stop
>
<!-- Editor mode -->
<div v-if="isEditor" class="flex items-center gap-2">
<Link class="h-4 w-4 flex-shrink-0 text-muted-foreground" />
<ElInput
ref="inputRef"
v-model="linkValue"
size="small"
:placeholder="t('draw.property.linkPlaceholder')"
class="w-60"
@keydown="onKeyDown"
/>
<button
class="rounded-md bg-primary px-3 py-1 text-xs text-primary-foreground hover:opacity-90"
@click="saveLink"
>
OK
</button>
<button
class="rounded-md p-1 text-muted-foreground hover:bg-accent"
@click="close"
>
<X class="h-4 w-4" />
</button>
</div>
<!-- Info mode -->
<div v-if="isInfo" class="flex items-center gap-2">
<Link class="h-4 w-4 flex-shrink-0 text-muted-foreground" />
<a
:href="currentLink"
class="max-w-60 truncate text-sm text-primary underline"
target="_blank"
rel="noopener noreferrer"
@click.prevent="openLink"
>
{{ currentLink }}
</a>
<button
class="rounded-md p-1 text-muted-foreground hover:bg-accent"
:title="t('draw.menu.editLink')"
@click="editLink"
>
<ExternalLink class="h-3.5 w-3.5" />
</button>
<button
class="rounded-md p-1 text-destructive hover:bg-accent"
:title="t('draw.menu.removeLink')"
@click="removeLink"
>
<Trash2 class="h-3.5 w-3.5" />
</button>
<button
class="rounded-md p-1 text-muted-foreground hover:bg-accent"
@click="close"
>
<X class="h-4 w-4" />
</button>
</div>
</div>
</template>
@@ -1,336 +0,0 @@
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue';
import { $t } from '@vben/locales';
import {
Menu,
FolderOpen,
Save,
Download,
FileImage,
FileJson,
FileCode,
Copy,
ChevronRight,
} from '@vben/icons';
import { useDrawStore } from '../store/draw-store';
import { serializeAsJSON, deserializeFromJSON } from '../data/json';
import { exportToBlob } from '../data/export-canvas';
import { exportToSvgString } from '../data/export-svg';
const store = useDrawStore();
const exportSubOpen = ref(false);
const menuRef = ref<HTMLElement | null>(null);
function toggleMenu() {
store.mainMenuOpen = !store.mainMenuOpen;
if (!store.mainMenuOpen) {
exportSubOpen.value = false;
}
}
function closeMenu() {
store.mainMenuOpen = false;
exportSubOpen.value = false;
}
function onClickOutside(e: MouseEvent) {
if (menuRef.value && !menuRef.value.contains(e.target as Node)) {
closeMenu();
}
}
onMounted(() => {
document.addEventListener('mousedown', onClickOutside);
});
onBeforeUnmount(() => {
document.removeEventListener('mousedown', onClickOutside);
});
function handleOpen() {
closeMenu();
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json,.excalidraw';
input.onchange = async () => {
const file = input.files?.[0];
if (!file) return;
const text = await file.text();
const data = deserializeFromJSON(text);
if (data) {
store.loadDrawData(data);
}
};
input.click();
}
function handleSaveTo() {
closeMenu();
const json = serializeAsJSON(
store.scene.getElements(),
store.getAppState(),
store.files,
);
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${store.getAppState().name || 'drawing'}.zqdraw.json`;
a.click();
URL.revokeObjectURL(url);
}
async function handleExportPng() {
closeMenu();
const appState = store.getAppState();
const blob = await exportToBlob(
store.scene.getNonDeletedElements(),
appState,
store.files,
store.imageCache,
{ scale: appState.exportScale, background: appState.exportBackground },
);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${appState.name || 'drawing'}.png`;
a.click();
URL.revokeObjectURL(url);
}
function handleExportSvg() {
closeMenu();
const appState = store.getAppState();
const svgStr = exportToSvgString(
store.scene.getNonDeletedElements(),
appState,
store.files,
{ background: appState.exportBackground },
);
const blob = new Blob([svgStr], { type: 'image/svg+xml' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${appState.name || 'drawing'}.svg`;
a.click();
URL.revokeObjectURL(url);
}
function handleExportJson() {
closeMenu();
handleSaveTo();
}
async function handleCopyPng() {
closeMenu();
const appState = store.getAppState();
const blob = await exportToBlob(
store.scene.getNonDeletedElements(),
appState,
store.files,
store.imageCache,
{ scale: appState.exportScale, background: appState.exportBackground },
);
await navigator.clipboard.write([
new ClipboardItem({ 'image/png': blob }),
]);
}
async function handleCopySvg() {
closeMenu();
const appState = store.getAppState();
const svgStr = exportToSvgString(
store.scene.getNonDeletedElements(),
appState,
store.files,
{ background: appState.exportBackground },
);
await navigator.clipboard.writeText(svgStr);
}
</script>
<template>
<div ref="menuRef" class="zq-draw-main-menu">
<button
class="zq-draw-main-menu-trigger"
:title="$t('draw.menu.openFile')"
@click="toggleMenu"
>
<Menu class="h-[18px] w-[18px]" />
</button>
<Transition name="zq-menu-fade">
<div v-if="store.mainMenuOpen" class="zq-draw-main-menu-dropdown">
<button class="zq-menu-item" @click="handleOpen">
<FolderOpen class="zq-menu-item-icon" />
<span>{{ $t('draw.menu.openFile') }}</span>
<span class="zq-menu-item-shortcut">Ctrl+O</span>
</button>
<button class="zq-menu-item" @click="handleSaveTo">
<Save class="zq-menu-item-icon" />
<span>{{ $t('draw.menu.saveTo') }}</span>
<span class="zq-menu-item-shortcut">Ctrl+Shift+S</span>
</button>
<div class="zq-menu-separator" />
<div
class="zq-menu-sub"
@mouseenter="exportSubOpen = true"
@mouseleave="exportSubOpen = false"
>
<button class="zq-menu-item">
<Download class="zq-menu-item-icon" />
<span>{{ $t('draw.menu.export') }}</span>
<ChevronRight class="zq-menu-item-arrow" />
</button>
<Transition name="zq-menu-fade">
<div v-if="exportSubOpen" class="zq-draw-main-menu-submenu">
<button class="zq-menu-item" @click="handleExportPng">
<FileImage class="zq-menu-item-icon" />
<span>{{ $t('draw.menu.exportPng') }}</span>
</button>
<button class="zq-menu-item" @click="handleExportSvg">
<FileCode class="zq-menu-item-icon" />
<span>{{ $t('draw.menu.exportSvg') }}</span>
</button>
<button class="zq-menu-item" @click="handleExportJson">
<FileJson class="zq-menu-item-icon" />
<span>{{ $t('draw.menu.exportJson') }}</span>
</button>
<div class="zq-menu-separator" />
<button class="zq-menu-item" @click="handleCopyPng">
<Copy class="zq-menu-item-icon" />
<span>{{ $t('draw.menu.exportCopyPng') }}</span>
</button>
<button class="zq-menu-item" @click="handleCopySvg">
<Copy class="zq-menu-item-icon" />
<span>{{ $t('draw.menu.exportCopySvg') }}</span>
</button>
</div>
</Transition>
</div>
</div>
</Transition>
</div>
</template>
<style scoped lang="scss">
.zq-draw-main-menu {
position: relative;
}
.zq-draw-main-menu-trigger {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border: 1px solid var(--el-border-color-light);
border-radius: 8px;
background: var(--el-bg-color);
cursor: pointer;
color: var(--el-text-color-regular);
box-shadow: 0 2px 8px rgb(0 0 0 / 8%);
transition: all 0.15s;
&:hover {
background: var(--el-fill-color-light);
}
}
.zq-draw-main-menu-dropdown {
position: absolute;
top: calc(100% + 6px);
left: 0;
min-width: 220px;
padding: 4px;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-light);
border-radius: 8px;
box-shadow: 0 4px 16px rgb(0 0 0 / 12%);
z-index: 100;
}
.zq-menu-item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 12px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--el-text-color-regular);
font-size: 13px;
cursor: pointer;
transition: background 0.15s;
white-space: nowrap;
&:hover {
background: var(--el-fill-color-light);
}
}
.zq-menu-item-icon {
width: 16px;
height: 16px;
flex-shrink: 0;
color: var(--el-text-color-secondary);
}
.zq-menu-item-shortcut {
margin-left: auto;
font-size: 12px;
color: var(--el-text-color-placeholder);
}
.zq-menu-item-arrow {
width: 14px;
height: 14px;
margin-left: auto;
flex-shrink: 0;
color: var(--el-text-color-placeholder);
}
.zq-menu-separator {
height: 1px;
margin: 4px 8px;
background: var(--el-border-color-lighter);
}
.zq-menu-sub {
position: relative;
}
.zq-draw-main-menu-submenu {
position: absolute;
top: -4px;
left: calc(100% + 4px);
min-width: 200px;
padding: 4px;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-light);
border-radius: 8px;
box-shadow: 0 4px 16px rgb(0 0 0 / 12%);
z-index: 101;
}
.zq-menu-fade-enter-active,
.zq-menu-fade-leave-active {
transition:
opacity 0.15s ease,
transform 0.15s ease;
}
.zq-menu-fade-enter-from,
.zq-menu-fade-leave-to {
opacity: 0;
transform: translateY(-4px);
}
</style>
@@ -1,972 +0,0 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useI18n } from '@vben/locales';
import { ElSlider } from 'element-plus';
import {
Link,
Minus as MinusIcon,
Plus as PlusIcon,
AlignStartHorizontal,
AlignCenterHorizontal,
AlignEndHorizontal,
AlignStartVertical,
AlignCenterVertical,
AlignEndVertical,
AlignHorizontalSpaceAround,
AlignVerticalSpaceAround,
ArrowDownToLine,
ArrowDown,
ArrowUp,
ArrowUpToLine,
Copy,
Trash2,
} from '@vben/icons';
import { useDrawStore } from '../store/draw-store';
import type {
FillStyle,
StrokeStyle,
Arrowhead,
DrawLinearElement,
DrawArrowElement,
DrawTextElement,
TextAlign,
VerticalAlign,
} from '../types';
import {
PRESET_STROKE_COLORS,
PRESET_BACKGROUND_COLORS,
FONT_FAMILY,
FONT_METADATA,
ROUNDNESS,
} from '../constants';
import {
FillHachureIcon,
FillCrossHatchIcon,
FillSolidIcon,
FillZigZagIcon,
StrokeWidthThinIcon,
StrokeWidthBoldIcon,
StrokeWidthExtraBoldIcon,
StrokeStyleSolidIcon,
StrokeStyleDashedIcon,
StrokeStyleDottedIcon,
SloppinessArchitectIcon,
SloppinessArtistIcon,
SloppinessCartoonistIcon,
EdgeSharpIcon,
EdgeRoundIcon,
ArrowTypeSharpIcon,
ArrowTypeRoundIcon,
ArrowTypeElbowIcon,
ArrowheadNoneIcon,
ArrowheadArrowIcon,
ArrowheadTriangleIcon,
ArrowheadBarIcon,
ArrowheadCircleIcon,
ArrowheadDiamondIcon,
ArrowheadArrowStartIcon,
ArrowheadTriangleStartIcon,
ArrowheadBarStartIcon,
ArrowheadCircleStartIcon,
ArrowheadDiamondStartIcon,
ArrowheadNoneStartIcon,
FontHandDrawnIcon,
FontNormalIcon,
FontCodeIcon,
TextAlignLeftIcon,
TextAlignCenterIcon,
TextAlignRightIcon,
VerticalAlignTopIcon,
VerticalAlignMiddleIcon,
VerticalAlignBottomIcon,
} from './icons/property-icons';
import {
redrawTextBoundingBox,
getContainerElement,
getBoundTextElement,
} from '../elements/bound-text';
const { t } = useI18n();
const store = useDrawStore();
const strokeColorInputRef = ref<HTMLInputElement | null>(null);
const bgColorInputRef = ref<HTMLInputElement | null>(null);
function openStrokeColorPicker() {
strokeColorInputRef.value?.click();
}
function onStrokeColorInput(e: Event) {
const val = (e.target as HTMLInputElement).value;
if (val) strokeColor.value = val;
}
function openBgColorPicker() {
bgColorInputRef.value?.click();
}
function onBgColorInput(e: Event) {
const val = (e.target as HTMLInputElement).value;
if (val) backgroundColor.value = val;
}
const isDrawingTool = computed(() => {
const t = store.activeTool.type;
return ['rectangle', 'ellipse', 'diamond', 'freedraw', 'arrow', 'line'].includes(t);
});
const showPanel = computed(() => {
return !store.viewModeEnabled && !store.mainMenuOpen && (store.selectedElements.length > 0 || isDrawingTool.value);
});
const isSingleSelect = computed(() => store.selectedElements.length === 1);
const isMultiSelect = computed(() => store.selectedElements.length > 1);
const primaryElement = computed(() => {
if (store.selectedElements.length === 0) return null;
return store.selectedElements[0]!;
});
const activeToolType = computed(() => store.activeTool.type);
const effectiveType = computed(() => {
if (primaryElement.value) return primaryElement.value.type;
if (isDrawingTool.value) return activeToolType.value;
return null;
});
const hasStrokeColor = computed(() => {
if (!effectiveType.value) return false;
return ['rectangle', 'ellipse', 'diamond', 'freedraw', 'arrow', 'line', 'text'].includes(effectiveType.value);
});
const hasBackground = computed(() => {
if (!effectiveType.value) return false;
return ['rectangle', 'ellipse', 'diamond', 'line', 'freedraw'].includes(effectiveType.value);
});
const showFillStyle = computed(() => {
if (!hasBackground.value) return false;
if (primaryElement.value) return primaryElement.value.backgroundColor !== 'transparent';
return store.currentItemBackgroundColor !== 'transparent';
});
const hasStrokeWidth = computed(() => {
if (!effectiveType.value) return false;
return ['rectangle', 'ellipse', 'diamond', 'freedraw', 'arrow', 'line'].includes(effectiveType.value);
});
const hasStrokeStyle = computed(() => {
if (!effectiveType.value) return false;
return ['rectangle', 'ellipse', 'diamond', 'arrow', 'line'].includes(effectiveType.value);
});
const canChangeRoundness = computed(() => {
if (!effectiveType.value) return false;
return ['rectangle', 'diamond', 'line', 'arrow', 'image'].includes(effectiveType.value);
});
const isLinearElement = computed(() => {
return effectiveType.value === 'arrow' || effectiveType.value === 'line';
});
const isArrowElement = computed(() => effectiveType.value === 'arrow');
const isTextOrHasText = computed(() => {
if (!primaryElement.value) return false;
if (primaryElement.value.type === 'text') return true;
return primaryElement.value.boundElements?.some((b) => b.type === 'text') ?? false;
});
const textElement = computed((): DrawTextElement | null => {
if (!primaryElement.value) return null;
if (primaryElement.value.type === 'text') return primaryElement.value as DrawTextElement;
const bound = primaryElement.value.boundElements?.find((b) => b.type === 'text');
if (bound) {
const el = store.scene.getElement(bound.id);
if (el && !el.isDeleted && el.type === 'text') return el as DrawTextElement;
}
return null;
});
// ---- Property values ----
const strokeColor = computed({
get: () => primaryElement.value?.strokeColor ?? store.currentItemStrokeColor,
set: (val: string) => {
store.recordHistory();
store.currentItemStrokeColor = val;
for (const el of store.selectedElements) {
store.updateElement(el.id, { strokeColor: val } as any);
}
},
});
const backgroundColor = computed({
get: () => primaryElement.value?.backgroundColor ?? store.currentItemBackgroundColor,
set: (val: string) => {
store.recordHistory();
store.currentItemBackgroundColor = val;
for (const el of store.selectedElements) {
store.updateElement(el.id, { backgroundColor: val } as any);
}
},
});
const fillStyle = computed({
get: () => (primaryElement.value?.fillStyle ?? store.currentItemFillStyle) as FillStyle,
set: (val: FillStyle) => {
store.recordHistory();
store.currentItemFillStyle = val;
for (const el of store.selectedElements) {
store.updateElement(el.id, { fillStyle: val } as any);
}
},
});
const strokeWidth = computed({
get: () => primaryElement.value?.strokeWidth ?? store.currentItemStrokeWidth,
set: (val: number) => {
store.recordHistory();
store.currentItemStrokeWidth = val;
for (const el of store.selectedElements) {
store.updateElement(el.id, { strokeWidth: val } as any);
}
},
});
const strokeStyle = computed({
get: () => (primaryElement.value?.strokeStyle ?? store.currentItemStrokeStyle) as StrokeStyle,
set: (val: StrokeStyle) => {
store.recordHistory();
store.currentItemStrokeStyle = val;
for (const el of store.selectedElements) {
store.updateElement(el.id, { strokeStyle: val } as any);
}
},
});
const roughness = computed({
get: () => primaryElement.value?.roughness ?? store.currentItemRoughness,
set: (val: number) => {
store.recordHistory();
store.currentItemRoughness = val;
for (const el of store.selectedElements) {
store.updateElement(el.id, { roughness: val } as any);
}
},
});
const isRound = computed({
get: () => {
if (!primaryElement.value) return true;
return primaryElement.value.roundness != null;
},
set: (val: boolean) => {
store.recordHistory();
for (const el of store.selectedElements) {
let roundness = null;
if (val) {
const isLinear = el.type === 'line' || el.type === 'arrow';
roundness = {
type: isLinear
? ROUNDNESS.PROPORTIONAL_RADIUS
: ROUNDNESS.ADAPTIVE_RADIUS,
};
}
store.updateElement(el.id, { roundness } as any);
}
},
});
const opacity = computed({
get: () => primaryElement.value?.opacity ?? store.currentItemOpacity,
set: (val: number) => {
store.recordHistory();
store.currentItemOpacity = val;
for (const el of store.selectedElements) {
store.updateElement(el.id, { opacity: val } as any);
}
},
});
const arrowType = computed({
get: () => {
if (!isArrowElement.value) return 'round';
return (primaryElement.value as DrawArrowElement)?.elbowed ? 'elbow' : store.currentItemArrowType;
},
set: (val: string) => {
store.recordHistory();
store.currentItemArrowType = val as any;
for (const el of store.selectedElements) {
if (el.type === 'arrow') {
store.updateElement(el.id, { elbowed: val === 'elbow' } as any);
if (val === 'elbow') store.updateBindingsAfterMove(el.id);
}
}
},
});
const startArrowhead = computed({
get: () => {
if (!isLinearElement.value) return null;
return (primaryElement.value as DrawLinearElement)?.startArrowhead ?? null;
},
set: (val: Arrowhead | null) => {
store.recordHistory();
store.currentItemStartArrowhead = val;
for (const el of store.selectedElements) {
if (el.type === 'arrow' || el.type === 'line') {
store.updateElement(el.id, { startArrowhead: val } as any);
}
}
},
});
const endArrowhead = computed({
get: () => {
if (!isLinearElement.value) return null;
return (primaryElement.value as DrawLinearElement)?.endArrowhead ?? null;
},
set: (val: Arrowhead | null) => {
store.recordHistory();
store.currentItemEndArrowhead = val;
for (const el of store.selectedElements) {
if (el.type === 'arrow' || el.type === 'line') {
store.updateElement(el.id, { endArrowhead: val } as any);
}
}
},
});
function updateTextProperty(updates: Partial<DrawTextElement>) {
for (const el of store.selectedElements) {
if (el.type === 'text') {
store.updateElement(el.id, updates as any);
const textEl = store.scene.getElement(el.id) as DrawTextElement;
if (textEl) {
const container = getContainerElement(textEl, store.scene);
redrawTextBoundingBox(textEl, container, store.scene);
}
} else {
const boundText = getBoundTextElement(el, store.scene);
if (boundText) {
store.updateElement(boundText.id, updates as any);
const updated = store.scene.getElement(boundText.id) as DrawTextElement;
if (updated) redrawTextBoundingBox(updated, el, store.scene);
}
}
}
store.requestRender();
}
const fontFamily = computed({
get: () => textElement.value?.fontFamily ?? store.currentItemFontFamily,
set: (val: number) => {
store.recordHistory();
store.currentItemFontFamily = val;
const lineHeight = FONT_METADATA[val]?.lineHeight ?? 1.25;
updateTextProperty({ fontFamily: val, lineHeight } as Partial<DrawTextElement>);
},
});
const fontSize = computed({
get: () => textElement.value?.fontSize ?? store.currentItemFontSize,
set: (val: number) => {
store.recordHistory();
store.currentItemFontSize = val;
updateTextProperty({ fontSize: val } as Partial<DrawTextElement>);
},
});
const textAlign = computed({
get: () => (textElement.value?.textAlign ?? 'left') as TextAlign,
set: (val: TextAlign) => {
store.recordHistory();
updateTextProperty({ textAlign: val } as Partial<DrawTextElement>);
},
});
const verticalAlign = computed({
get: () => (textElement.value?.verticalAlign ?? 'top') as VerticalAlign,
set: (val: VerticalAlign) => {
store.recordHistory();
updateTextProperty({ verticalAlign: val } as Partial<DrawTextElement>);
},
});
// ---- Options data ----
const fillStyleOptions = [
{ value: 'hachure' as FillStyle, icon: FillHachureIcon, label: 'draw.fillStyle.hachure' },
{ value: 'cross-hatch' as FillStyle, icon: FillCrossHatchIcon, label: 'draw.fillStyle.crossHatch' },
{ value: 'solid' as FillStyle, icon: FillSolidIcon, label: 'draw.fillStyle.solid' },
{ value: 'zigzag' as FillStyle, icon: FillZigZagIcon, label: 'draw.fillStyle.zigzag' },
];
const strokeWidthOptions = [
{ value: 1, icon: StrokeWidthThinIcon, label: 'draw.label.thin' },
{ value: 2, icon: StrokeWidthBoldIcon, label: 'draw.label.bold' },
{ value: 4, icon: StrokeWidthExtraBoldIcon, label: 'draw.label.extraBold' },
];
const strokeStyleOptions = [
{ value: 'solid' as StrokeStyle, icon: StrokeStyleSolidIcon, label: 'draw.strokeStyle.solid' },
{ value: 'dashed' as StrokeStyle, icon: StrokeStyleDashedIcon, label: 'draw.strokeStyle.dashed' },
{ value: 'dotted' as StrokeStyle, icon: StrokeStyleDottedIcon, label: 'draw.strokeStyle.dotted' },
];
const sloppinessOptions = [
{ value: 0, icon: SloppinessArchitectIcon, label: 'draw.label.architect' },
{ value: 1, icon: SloppinessArtistIcon, label: 'draw.label.artist' },
{ value: 2, icon: SloppinessCartoonistIcon, label: 'draw.label.cartoonist' },
];
const arrowTypeOptions = [
{ value: 'sharp', icon: ArrowTypeSharpIcon, label: 'draw.property.arrowTypeSharp' },
{ value: 'round', icon: ArrowTypeRoundIcon, label: 'draw.property.arrowTypeRound' },
{ value: 'elbow', icon: ArrowTypeElbowIcon, label: 'draw.property.arrowTypeElbow' },
];
const startArrowheadOptions = [
{ value: null as Arrowhead | null, icon: ArrowheadNoneStartIcon, label: 'draw.arrowhead.none' },
{ value: 'arrow' as Arrowhead, icon: ArrowheadArrowStartIcon, label: 'draw.arrowhead.arrow' },
{ value: 'triangle' as Arrowhead, icon: ArrowheadTriangleStartIcon, label: 'draw.arrowhead.triangle' },
{ value: 'bar' as Arrowhead, icon: ArrowheadBarStartIcon, label: 'draw.arrowhead.bar' },
{ value: 'circle' as Arrowhead, icon: ArrowheadCircleStartIcon, label: 'draw.arrowhead.circle' },
{ value: 'diamond' as Arrowhead, icon: ArrowheadDiamondStartIcon, label: 'draw.arrowhead.diamond' },
];
const endArrowheadOptions = [
{ value: null as Arrowhead | null, icon: ArrowheadNoneIcon, label: 'draw.arrowhead.none' },
{ value: 'arrow' as Arrowhead, icon: ArrowheadArrowIcon, label: 'draw.arrowhead.arrow' },
{ value: 'triangle' as Arrowhead, icon: ArrowheadTriangleIcon, label: 'draw.arrowhead.triangle' },
{ value: 'bar' as Arrowhead, icon: ArrowheadBarIcon, label: 'draw.arrowhead.bar' },
{ value: 'circle' as Arrowhead, icon: ArrowheadCircleIcon, label: 'draw.arrowhead.circle' },
{ value: 'diamond' as Arrowhead, icon: ArrowheadDiamondIcon, label: 'draw.arrowhead.diamond' },
];
const fontFamilyOptions = [
{ value: FONT_FAMILY.Excalifont, icon: FontHandDrawnIcon, label: 'draw.font.handDrawn' },
{ value: FONT_FAMILY.Nunito, icon: FontNormalIcon, label: 'draw.font.normal' },
{ value: FONT_FAMILY['Comic Shanns'], icon: FontCodeIcon, label: 'draw.font.code' },
];
const fontSizeOptions = [16, 20, 28, 36, 48, 64];
const hasLink = computed(() => {
return primaryElement.value?.link != null && primaryElement.value.link !== '';
});
const canDistribute = computed(() => store.selectedElements.length >= 3);
function openHyperlinkEditor() {
store.showHyperlinkPopup = 'editor';
}
</script>
<template>
<div
v-if="showPanel"
class="zq-draw-property-panel absolute right-2 top-8 z-20 w-[228px] overflow-y-auto rounded-lg border border-border bg-card p-3 shadow-md"
style="max-height: calc(100% - 80px)"
>
<!-- Stroke Color -->
<fieldset v-if="hasStrokeColor" class="mb-3">
<legend class="mb-1.5 text-xs font-medium text-muted-foreground">
{{ t('draw.property.strokeColor') }}
</legend>
<div class="flex flex-wrap gap-1.5">
<button
v-for="color in PRESET_STROKE_COLORS"
:key="color"
class="prop-color-btn"
:class="{ 'ring-2 ring-primary ring-offset-1 ring-offset-card': strokeColor === color }"
:style="{ backgroundColor: color }"
:title="color"
@click="strokeColor = color"
/>
<button
class="prop-color-btn prop-color-custom"
:class="{ 'ring-2 ring-primary ring-offset-1 ring-offset-card': !PRESET_STROKE_COLORS.includes(strokeColor) && strokeColor !== '' }"
:style="!PRESET_STROKE_COLORS.includes(strokeColor) ? { backgroundColor: strokeColor } : {}"
:title="t('draw.property.customColor')"
@click="openStrokeColorPicker"
/>
<input
ref="strokeColorInputRef"
type="color"
class="prop-color-native-input"
:value="strokeColor"
@input="onStrokeColorInput"
/>
</div>
</fieldset>
<!-- Background Color -->
<fieldset v-if="hasBackground" class="mb-3">
<legend class="mb-1.5 text-xs font-medium text-muted-foreground">
{{ t('draw.property.backgroundColor') }}
</legend>
<div class="flex flex-wrap gap-1.5">
<button
v-for="color in PRESET_BACKGROUND_COLORS"
:key="color"
class="prop-color-btn"
:class="{
'ring-2 ring-primary ring-offset-1 ring-offset-card': backgroundColor === color,
'prop-color-transparent': color === 'transparent',
}"
:style="color !== 'transparent' ? { backgroundColor: color } : {}"
:title="color === 'transparent' ? 'Transparent' : color"
@click="backgroundColor = color"
/>
<button
class="prop-color-btn prop-color-custom"
:class="{ 'ring-2 ring-primary ring-offset-1 ring-offset-card': !PRESET_BACKGROUND_COLORS.includes(backgroundColor) && backgroundColor !== '' }"
:style="!PRESET_BACKGROUND_COLORS.includes(backgroundColor) ? { backgroundColor } : {}"
:title="t('draw.property.customColor')"
@click="openBgColorPicker"
/>
<input
ref="bgColorInputRef"
type="color"
class="prop-color-native-input"
:value="backgroundColor === 'transparent' ? '#ffffff' : backgroundColor"
@input="onBgColorInput"
/>
</div>
</fieldset>
<!-- Fill Style -->
<fieldset v-if="showFillStyle" class="mb-3">
<legend class="mb-1.5 text-xs font-medium text-muted-foreground">
{{ t('draw.property.fillStyle') }}
</legend>
<div class="flex gap-1">
<button
v-for="opt in fillStyleOptions"
:key="opt.value"
class="prop-icon-btn"
:class="{ 'prop-icon-btn--active': fillStyle === opt.value }"
:title="t(opt.label)"
@click="fillStyle = opt.value"
v-html="opt.icon"
/>
</div>
</fieldset>
<!-- Stroke Width -->
<fieldset v-if="hasStrokeWidth" class="mb-3">
<legend class="mb-1.5 text-xs font-medium text-muted-foreground">
{{ t('draw.property.strokeWidth') }}
</legend>
<div class="flex gap-1">
<button
v-for="opt in strokeWidthOptions"
:key="opt.value"
class="prop-icon-btn"
:class="{ 'prop-icon-btn--active': strokeWidth === opt.value }"
:title="t(opt.label)"
@click="strokeWidth = opt.value"
v-html="opt.icon"
/>
</div>
</fieldset>
<!-- Stroke Style + Sloppiness -->
<fieldset v-if="hasStrokeStyle" class="mb-3">
<legend class="mb-1.5 text-xs font-medium text-muted-foreground">
{{ t('draw.property.strokeStyle') }}
</legend>
<div class="flex gap-1">
<button
v-for="opt in strokeStyleOptions"
:key="opt.value"
class="prop-icon-btn"
:class="{ 'prop-icon-btn--active': strokeStyle === opt.value }"
:title="t(opt.label)"
@click="strokeStyle = opt.value"
v-html="opt.icon"
/>
</div>
</fieldset>
<fieldset v-if="hasStrokeStyle" class="mb-3">
<legend class="mb-1.5 text-xs font-medium text-muted-foreground">
{{ t('draw.property.sloppiness') }}
</legend>
<div class="flex gap-1">
<button
v-for="opt in sloppinessOptions"
:key="opt.value"
class="prop-icon-btn"
:class="{ 'prop-icon-btn--active': roughness === opt.value }"
:title="t(opt.label)"
@click="roughness = opt.value"
v-html="opt.icon"
/>
</div>
</fieldset>
<!-- Edges (Roundness) -->
<fieldset v-if="canChangeRoundness" class="mb-3">
<legend class="mb-1.5 text-xs font-medium text-muted-foreground">
{{ t('draw.property.edges') }}
</legend>
<div class="flex gap-1">
<button
class="prop-icon-btn"
:class="{ 'prop-icon-btn--active': !isRound }"
:title="t('draw.property.edgesSharp')"
@click="isRound = false"
v-html="EdgeSharpIcon"
/>
<button
class="prop-icon-btn"
:class="{ 'prop-icon-btn--active': isRound }"
:title="t('draw.property.edgesRound')"
@click="isRound = true"
v-html="EdgeRoundIcon"
/>
</div>
</fieldset>
<!-- Arrow Type -->
<fieldset v-if="isArrowElement" class="mb-3">
<legend class="mb-1.5 text-xs font-medium text-muted-foreground">
{{ t('draw.property.arrowType') }}
</legend>
<div class="flex gap-1">
<button
v-for="opt in arrowTypeOptions"
:key="opt.value"
class="prop-icon-btn"
:class="{ 'prop-icon-btn--active': arrowType === opt.value }"
:title="t(opt.label)"
@click="arrowType = opt.value"
v-html="opt.icon"
/>
</div>
</fieldset>
<!-- Font Family -->
<fieldset v-if="isTextOrHasText" class="mb-3">
<legend class="mb-1.5 text-xs font-medium text-muted-foreground">
{{ t('draw.property.fontFamily') }}
</legend>
<div class="flex gap-1">
<button
v-for="opt in fontFamilyOptions"
:key="opt.value"
class="prop-icon-btn"
:class="{ 'prop-icon-btn--active': fontFamily === opt.value }"
:title="t(opt.label)"
@click="fontFamily = opt.value"
v-html="opt.icon"
/>
</div>
</fieldset>
<!-- Font Size -->
<fieldset v-if="isTextOrHasText" class="mb-3">
<legend class="mb-1.5 text-xs font-medium text-muted-foreground">
{{ t('draw.property.fontSize') }}
</legend>
<div class="flex flex-wrap gap-1">
<button
v-for="size in fontSizeOptions"
:key="size"
class="prop-text-btn"
:class="{ 'prop-text-btn--active': fontSize === size }"
@click="fontSize = size"
>
{{ size }}
</button>
</div>
<div class="mt-1.5 flex items-center gap-0.5">
<button class="prop-sm-btn" @click="fontSize = Math.max(8, fontSize - 2)">
<MinusIcon class="h-3 w-3" />
</button>
<span class="min-w-[2rem] text-center text-xs">{{ fontSize }}</span>
<button class="prop-sm-btn" @click="fontSize = Math.min(120, fontSize + 2)">
<PlusIcon class="h-3 w-3" />
</button>
</div>
</fieldset>
<!-- Text Align -->
<fieldset v-if="isTextOrHasText" class="mb-3">
<legend class="mb-1.5 text-xs font-medium text-muted-foreground">
{{ t('draw.property.textAlign') }}
</legend>
<div class="flex gap-1">
<button
class="prop-icon-btn"
:class="{ 'prop-icon-btn--active': textAlign === 'left' }"
:title="'Left'"
@click="textAlign = 'left'"
v-html="TextAlignLeftIcon"
/>
<button
class="prop-icon-btn"
:class="{ 'prop-icon-btn--active': textAlign === 'center' }"
:title="'Center'"
@click="textAlign = 'center'"
v-html="TextAlignCenterIcon"
/>
<button
class="prop-icon-btn"
:class="{ 'prop-icon-btn--active': textAlign === 'right' }"
:title="'Right'"
@click="textAlign = 'right'"
v-html="TextAlignRightIcon"
/>
</div>
</fieldset>
<!-- Vertical Align -->
<fieldset v-if="isTextOrHasText && primaryElement?.type !== 'text'" class="mb-3">
<legend class="mb-1.5 text-xs font-medium text-muted-foreground">
{{ t('draw.property.verticalAlign') }}
</legend>
<div class="flex gap-1">
<button
class="prop-icon-btn"
:class="{ 'prop-icon-btn--active': verticalAlign === 'top' }"
:title="t('draw.verticalAlign.top')"
@click="verticalAlign = 'top'"
v-html="VerticalAlignTopIcon"
/>
<button
class="prop-icon-btn"
:class="{ 'prop-icon-btn--active': verticalAlign === 'middle' }"
:title="t('draw.verticalAlign.middle')"
@click="verticalAlign = 'middle'"
v-html="VerticalAlignMiddleIcon"
/>
<button
class="prop-icon-btn"
:class="{ 'prop-icon-btn--active': verticalAlign === 'bottom' }"
:title="t('draw.verticalAlign.bottom')"
@click="verticalAlign = 'bottom'"
v-html="VerticalAlignBottomIcon"
/>
</div>
</fieldset>
<!-- Arrowheads -->
<fieldset v-if="isLinearElement" class="mb-3">
<legend class="mb-1.5 text-xs font-medium text-muted-foreground">
{{ t('draw.property.arrowheadStart') }}
</legend>
<div class="flex gap-0.5">
<button
v-for="opt in startArrowheadOptions"
:key="String(opt.value)"
class="prop-arrowhead-btn"
:class="{ 'prop-arrowhead-btn--active': startArrowhead === opt.value }"
:title="t(opt.label)"
@click="startArrowhead = opt.value"
v-html="opt.icon"
/>
</div>
<legend class="mb-1.5 mt-2 text-xs font-medium text-muted-foreground">
{{ t('draw.property.arrowheadEnd') }}
</legend>
<div class="flex gap-0.5">
<button
v-for="opt in endArrowheadOptions"
:key="String(opt.value)"
class="prop-arrowhead-btn"
:class="{ 'prop-arrowhead-btn--active': endArrowhead === opt.value }"
:title="t(opt.label)"
@click="endArrowhead = opt.value"
v-html="opt.icon"
/>
</div>
</fieldset>
<!-- Opacity -->
<fieldset class="mb-3">
<legend class="mb-1 text-xs font-medium text-muted-foreground">
{{ t('draw.property.opacity') }}
</legend>
<div class="flex items-center gap-2">
<ElSlider
:model-value="opacity"
:min="0"
:max="100"
:step="10"
class="flex-1"
@update:model-value="(v: any) => opacity = Number(v)"
/>
<span class="w-8 text-right text-xs text-muted-foreground">{{ opacity }}</span>
</div>
</fieldset>
<!-- Layers -->
<fieldset v-if="isSingleSelect || isMultiSelect" class="mb-3">
<legend class="mb-1.5 text-xs font-medium text-muted-foreground">
{{ t('draw.property.layers') }}
</legend>
<div class="flex gap-1">
<button
class="prop-action-btn"
:title="t('draw.menu.sendToBack')"
@click="store.sendToBack()"
>
<ArrowDownToLine class="h-4 w-4" />
</button>
<button
class="prop-action-btn"
:title="t('draw.menu.sendBackward')"
@click="store.sendBackward()"
>
<ArrowDown class="h-4 w-4" />
</button>
<button
class="prop-action-btn"
:title="t('draw.menu.bringForward')"
@click="store.bringForward()"
>
<ArrowUp class="h-4 w-4" />
</button>
<button
class="prop-action-btn"
:title="t('draw.menu.bringToFront')"
@click="store.bringToFront()"
>
<ArrowUpToLine class="h-4 w-4" />
</button>
</div>
</fieldset>
<!-- Actions -->
<fieldset v-if="isSingleSelect || isMultiSelect" class="mb-3">
<legend class="mb-1.5 text-xs font-medium text-muted-foreground">
{{ t('draw.property.actions') }}
</legend>
<div class="flex gap-1">
<button
class="prop-action-btn"
:title="t('draw.menu.duplicate')"
@click="store.duplicateSelectedElements()"
>
<Copy class="h-4 w-4" />
</button>
<button
class="prop-action-btn"
:title="t('draw.menu.delete')"
@click="store.deleteSelectedElementsWithBindings()"
>
<Trash2 class="h-4 w-4" />
</button>
<button
class="prop-action-btn"
:title="hasLink ? t('draw.menu.editLink') : t('draw.menu.addLink')"
@click="openHyperlinkEditor"
>
<Link class="h-4 w-4" />
</button>
</div>
</fieldset>
<!-- Align / Distribute (multi-select) -->
<fieldset v-if="isMultiSelect" class="mb-3">
<legend class="mb-1.5 text-xs font-medium text-muted-foreground">
{{ t('draw.property.alignDistribute') }}
</legend>
<div class="mb-1 flex gap-1">
<button class="prop-sm-btn" :title="t('draw.menu.alignLeft')" @click="store.alignSelected({ position: 'start', axis: 'x' })">
<AlignStartHorizontal class="h-3.5 w-3.5" />
</button>
<button class="prop-sm-btn" :title="t('draw.menu.alignCenter')" @click="store.alignSelected({ position: 'center', axis: 'x' })">
<AlignCenterHorizontal class="h-3.5 w-3.5" />
</button>
<button class="prop-sm-btn" :title="t('draw.menu.alignRight')" @click="store.alignSelected({ position: 'end', axis: 'x' })">
<AlignEndHorizontal class="h-3.5 w-3.5" />
</button>
<button class="prop-sm-btn" :title="t('draw.menu.alignTop')" @click="store.alignSelected({ position: 'start', axis: 'y' })">
<AlignStartVertical class="h-3.5 w-3.5" />
</button>
<button class="prop-sm-btn" :title="t('draw.menu.alignMiddle')" @click="store.alignSelected({ position: 'center', axis: 'y' })">
<AlignCenterVertical class="h-3.5 w-3.5" />
</button>
<button class="prop-sm-btn" :title="t('draw.menu.alignBottom')" @click="store.alignSelected({ position: 'end', axis: 'y' })">
<AlignEndVertical class="h-3.5 w-3.5" />
</button>
</div>
<div v-if="canDistribute" class="flex gap-1">
<button class="prop-sm-btn" :title="t('draw.menu.distributeH')" @click="store.distributeSelected({ space: 'between', axis: 'x' })">
<AlignHorizontalSpaceAround class="h-3.5 w-3.5" />
</button>
<button class="prop-sm-btn" :title="t('draw.menu.distributeV')" @click="store.distributeSelected({ space: 'between', axis: 'y' })">
<AlignVerticalSpaceAround class="h-3.5 w-3.5" />
</button>
</div>
</fieldset>
<div v-if="isMultiSelect" class="text-xs text-muted-foreground">
{{ t('draw.property.mixed') }}
</div>
</div>
</template>
<style scoped>
.prop-color-btn {
@apply h-[22px] w-[22px] rounded-md border border-border cursor-pointer transition-all hover:scale-110;
}
.prop-color-transparent {
background: repeating-conic-gradient(#ccc 0 25%, transparent 0 50%) 0 0 / 8px 8px;
}
.prop-color-custom {
background: conic-gradient(
from 0deg,
#ff0000, #ff8800, #ffff00, #00ff00, #00ffff, #0000ff, #8800ff, #ff0088, #ff0000
);
}
.prop-color-native-input {
@apply absolute h-0 w-0 overflow-hidden;
opacity: 0;
pointer-events: none;
}
.prop-icon-btn {
@apply flex h-8 w-8 items-center justify-center rounded-md border border-transparent cursor-pointer transition-colors text-foreground;
}
.prop-icon-btn:hover {
@apply bg-accent;
}
.prop-icon-btn--active {
@apply bg-primary/10 border-primary text-primary;
}
.prop-icon-btn :deep(svg) {
@apply h-5 w-5;
}
.prop-arrowhead-btn {
@apply flex h-7 flex-1 items-center justify-center rounded-md border border-transparent cursor-pointer transition-colors text-foreground;
}
.prop-arrowhead-btn:hover {
@apply bg-accent;
}
.prop-arrowhead-btn--active {
@apply bg-primary/10 border-primary text-primary;
}
.prop-arrowhead-btn :deep(svg) {
@apply h-5 w-full;
}
.prop-text-btn {
@apply flex h-7 min-w-[28px] items-center justify-center rounded-md border border-transparent px-1.5 text-xs cursor-pointer transition-colors;
}
.prop-text-btn:hover {
@apply bg-accent;
}
.prop-text-btn--active {
@apply bg-primary/10 border-primary text-primary;
}
.prop-action-btn {
@apply flex h-9 w-9 items-center justify-center rounded-lg cursor-pointer transition-colors text-foreground hover:bg-accent;
}
.prop-sm-btn {
@apply flex h-7 w-7 items-center justify-center rounded-md cursor-pointer transition-colors hover:bg-accent;
}
</style>
@@ -1,230 +0,0 @@
<script setup lang="ts">
import { computed, ref, nextTick } from 'vue';
import { useI18n } from '@vben/locales';
import { useDrawStore } from '../store/draw-store';
import { getElementBounds, getCommonBounds } from '../elements/bounds';
import type { DrawTextElement } from '../types';
const { t } = useI18n();
const store = useDrawStore();
const editInputRef = ref<HTMLInputElement | null>(null);
const show = computed(() => store.showStats && !store.viewModeEnabled);
const totalElements = computed(() => {
void store.sceneVersion;
return store.elements.length;
});
const sceneBounds = computed(() => {
void store.sceneVersion;
const els = store.elements;
if (els.length === 0) return { w: 0, h: 0 };
const [x1, y1, x2, y2] = getCommonBounds(els);
return { w: Math.round(x2 - x1), h: Math.round(y2 - y1) };
});
const selected = computed(() => store.selectedElements);
const isSingle = computed(() => selected.value.length === 1);
const isMulti = computed(() => selected.value.length > 1);
const hasSelection = computed(() => selected.value.length > 0);
const selBounds = computed(() => {
if (!hasSelection.value) return null;
if (isSingle.value) {
const el = selected.value[0]!;
const [x1, y1, x2, y2] = getElementBounds(el);
return {
x: Math.round(x1 * 100) / 100,
y: Math.round(y1 * 100) / 100,
w: Math.round((x2 - x1) * 100) / 100,
h: Math.round((y2 - y1) * 100) / 100,
};
}
const [x1, y1, x2, y2] = getCommonBounds(selected.value);
return {
x: Math.round(x1 * 100) / 100,
y: Math.round(y1 * 100) / 100,
w: Math.round((x2 - x1) * 100) / 100,
h: Math.round((y2 - y1) * 100) / 100,
};
});
const selAngle = computed(() => {
if (!isSingle.value) return null;
const el = selected.value[0]!;
return Math.round(((el.angle * 180) / Math.PI) * 100) / 100;
});
const isTextSelected = computed(() => {
return isSingle.value && selected.value[0]?.type === 'text';
});
const selFontSize = computed(() => {
if (!isTextSelected.value) return null;
return (selected.value[0] as DrawTextElement).fontSize;
});
const editingField = ref<string | null>(null);
const editValue = ref('');
function startEdit(field: string, currentValue: number | null) {
if (currentValue === null) return;
editingField.value = field;
editValue.value = String(currentValue);
nextTick(() => {
const el = editInputRef.value;
if (Array.isArray(el)) {
(el[0] as HTMLInputElement | undefined)?.select();
} else {
el?.select();
}
});
}
function commitEdit() {
const field = editingField.value;
if (!field) return;
const val = Number.parseFloat(editValue.value);
if (Number.isNaN(val)) {
editingField.value = null;
return;
}
store.recordHistory();
if (field === 'x' && selBounds.value) {
const dx = val - selBounds.value.x;
for (const el of selected.value) {
store.updateElement(el.id, { x: el.x + dx } as any);
}
} else if (field === 'y' && selBounds.value) {
const dy = val - selBounds.value.y;
for (const el of selected.value) {
store.updateElement(el.id, { y: el.y + dy } as any);
}
} else if (field === 'w' && isSingle.value) {
store.updateElement(selected.value[0]!.id, { width: Math.max(1, val) } as any);
} else if (field === 'h' && isSingle.value) {
store.updateElement(selected.value[0]!.id, { height: Math.max(1, val) } as any);
} else if (field === 'a' && isSingle.value) {
const radians = (val * Math.PI) / 180;
store.updateElement(selected.value[0]!.id, { angle: radians } as any);
} else if (field === 'f' && isTextSelected.value) {
store.updateElement(selected.value[0]!.id, { fontSize: Math.max(4, val) } as any);
}
store.requestRender();
editingField.value = null;
}
function onInputKeydown(e: KeyboardEvent) {
e.stopPropagation();
if (e.key === 'Enter') {
e.preventDefault();
commitEdit();
} else if (e.key === 'Escape') {
e.preventDefault();
editingField.value = null;
}
}
</script>
<template>
<div
v-if="show"
class="absolute bottom-14 right-3 z-20 min-w-44 rounded-lg border border-border bg-card p-2.5 text-xs shadow-md"
>
<div class="mb-2 flex items-center justify-between">
<span class="font-medium text-foreground">{{ t('draw.stats.title') }}</span>
<button
class="flex h-5 w-5 items-center justify-center rounded text-muted-foreground hover:bg-accent"
@click="store.showStats = false"
>
&times;
</button>
</div>
<div class="mb-1.5 text-muted-foreground">
{{ t('draw.stats.elements') }}: {{ totalElements }}
</div>
<div class="mb-2 text-muted-foreground">
{{ t('draw.stats.sceneSize') }}: {{ sceneBounds.w }} &times; {{ sceneBounds.h }}
</div>
<template v-if="hasSelection && selBounds">
<div class="mb-1.5 border-t border-border pt-1.5 font-medium text-foreground">
{{ isMulti ? t('draw.stats.multiSelected', { count: selected.length }) : selected[0]?.type }}
</div>
<div class="grid grid-cols-2 gap-x-3 gap-y-1">
<div
v-for="item in [
{ label: 'X', field: 'x', value: selBounds.x, editable: true },
{ label: 'Y', field: 'y', value: selBounds.y, editable: true },
{ label: 'W', field: 'w', value: selBounds.w, editable: isSingle },
{ label: 'H', field: 'h', value: selBounds.h, editable: isSingle },
]"
:key="item.field"
class="flex items-center gap-1"
>
<span class="w-4 font-medium text-muted-foreground">{{ item.label }}</span>
<input
v-if="editingField === item.field"
ref="editInputRef"
v-model="editValue"
class="h-5 w-full rounded border border-primary bg-background px-1 text-xs text-foreground outline-none"
@blur="commitEdit"
@keydown="onInputKeydown"
/>
<button
v-else
class="h-5 w-full truncate rounded px-1 text-left text-foreground"
:class="item.editable ? 'hover:bg-accent cursor-pointer' : 'opacity-60 cursor-default'"
@click="item.editable && startEdit(item.field, item.value)"
>
{{ item.value }}
</button>
</div>
</div>
<div v-if="isSingle && selAngle !== null" class="mt-1 flex items-center gap-1">
<span class="w-4 font-medium text-muted-foreground">A</span>
<input
v-if="editingField === 'a'"
ref="editInputRef"
v-model="editValue"
class="h-5 w-full rounded border border-primary bg-background px-1 text-xs text-foreground outline-none"
@blur="commitEdit"
@keydown="onInputKeydown"
/>
<button
v-else
class="h-5 w-full truncate rounded px-1 text-left text-foreground hover:bg-accent"
@click="startEdit('a', selAngle)"
>
{{ selAngle }}&deg;
</button>
</div>
<div v-if="isTextSelected && selFontSize !== null" class="mt-1 flex items-center gap-1">
<span class="w-4 font-medium text-muted-foreground">F</span>
<input
v-if="editingField === 'f'"
ref="editInputRef"
v-model="editValue"
class="h-5 w-full rounded border border-primary bg-background px-1 text-xs text-foreground outline-none"
@blur="commitEdit"
@keydown="onInputKeydown"
/>
<button
v-else
class="h-5 w-full truncate rounded px-1 text-left text-foreground hover:bg-accent"
@click="startEdit('f', selFontSize)"
>
{{ selFontSize }}
</button>
</div>
</template>
</div>
</template>
@@ -1,356 +0,0 @@
<script setup lang="ts">
import { ref, computed, watch, nextTick } from 'vue';
import { useDrawStore } from '../store/draw-store';
import { FONT_FAMILY_FALLBACKS } from '../constants';
import { sceneCoordsToViewport } from '../core/renderer/helpers';
import type { DrawTextElement, DrawElement, DrawLinearElement } from '../types';
import {
getContainerElement,
getBoundTextMaxWidth,
getBoundTextMaxHeight,
measureText,
computeBoundTextPosition,
computeContainerDimensionForBoundText,
getContainerTextCoords,
getArrowLabelPosition,
redrawTextBoundingBox,
getLineHeightInPx,
BOUND_TEXT_PADDING,
} from '../elements/bound-text';
const store = useDrawStore();
const textareaRef = ref<HTMLTextAreaElement | null>(null);
const isEditing = computed(() => !!store.editingTextElement);
const editingElement = computed<DrawTextElement | null>(() => {
if (!store.editingTextElement) return null;
const el = store.scene.getElement(store.editingTextElement.id);
return (el as DrawTextElement) ?? store.editingTextElement;
});
const container = computed<DrawElement | null>(() => {
const el = editingElement.value;
if (!el?.containerId) return null;
return getContainerElement(el, store.scene);
});
const isArrowLabel = computed(() => {
const c = container.value;
return c?.type === 'arrow' || c?.type === 'line';
});
const editorPosition = computed(() => {
const el = editingElement.value;
if (!el) return { x: 0, y: 0 };
const c = container.value;
if (c && isArrowLabel.value) {
const arrow = c as DrawLinearElement;
const center = getArrowLabelPosition(arrow);
const textW = el.width || 0;
const minW = el.fontSize * 2;
const editorW = Math.max(textW, minW);
return {
x: center.x - editorW / 2,
y: center.y - (el.height || 0) / 2,
};
}
if (c) {
const maxHeight = getBoundTextMaxHeight(c, el);
const textHeight = el.height || 0;
let coords: { x: number; y: number };
if (c.type === 'ellipse' || c.type === 'diamond') {
const cx = c.x + c.width / 2;
const cy = c.y + c.height / 2;
const maxW = getBoundTextMaxWidth(c);
coords = {
x: cx - maxW / 2,
y: cy - maxHeight / 2,
};
} else {
coords = getContainerTextCoords(c);
}
let offsetY = 0;
if (el.verticalAlign === 'middle') {
offsetY = (maxHeight - textHeight) / 2;
} else if (el.verticalAlign === 'bottom') {
offsetY = maxHeight - textHeight;
}
return {
x: coords.x,
y: coords.y + offsetY,
};
}
return { x: el.x, y: el.y };
});
const style = computed(() => {
const el = editingElement.value;
if (!el) return {};
const pos = editorPosition.value;
const { x, y } = sceneCoordsToViewport(pos.x, pos.y, {
scrollX: store.scrollX,
scrollY: store.scrollY,
zoom: store.zoom,
});
const fontFamily = FONT_FAMILY_FALLBACKS[el.fontFamily] ?? 'sans-serif';
const fontSize = el.fontSize * store.zoom.value;
const lineHeightPx = getLineHeightInPx(el.fontSize, el.lineHeight);
const lineHeightRatio = lineHeightPx / el.fontSize;
const c = container.value;
const isBound = !!c;
const isArrow = isArrowLabel.value;
let width: string;
let whiteSpace: string;
let wordBreak: string;
let textAlign: string;
if (isBound && !isArrow) {
const maxW = getBoundTextMaxWidth(c!) * store.zoom.value;
width = `${maxW}px`;
whiteSpace = 'pre-wrap';
wordBreak = 'break-word';
textAlign = el.textAlign;
} else if (isArrow) {
const textW = el.width || 0;
const minW = el.fontSize * 2;
width = `${Math.max(textW, minW) * store.zoom.value}px`;
whiteSpace = 'pre';
wordBreak = 'normal';
textAlign = 'center';
} else {
width = el.autoResize ? 'auto' : `${el.width * store.zoom.value}px`;
whiteSpace = el.autoResize ? 'pre' : 'pre-wrap';
wordBreak = el.autoResize ? 'normal' : 'break-word';
textAlign = el.textAlign;
}
const angle = (isArrow ? 0 : (c?.angle ?? el.angle)) || 0;
const bgColor = isArrow
? (store.viewBackgroundColor || '#ffffff')
: 'transparent';
const pad = isArrow ? `${BOUND_TEXT_PADDING * store.zoom.value}px` : '0';
return {
position: 'absolute' as const,
left: `${x}px`,
top: `${y}px`,
fontSize: `${fontSize}px`,
fontFamily,
color: el.strokeColor,
textAlign,
lineHeight: String(lineHeightRatio),
opacity: el.opacity / 100,
border: 'none',
outline: 'none',
background: bgColor,
resize: 'none' as const,
overflow: 'hidden',
minWidth: isArrow ? undefined : '1em',
minHeight: `${fontSize + 4}px`,
padding: pad,
margin: isArrow ? `-${pad}` : '0',
zIndex: 100,
whiteSpace: whiteSpace as any,
wordBreak: wordBreak as any,
width,
transformOrigin: '0 0',
transform: angle ? `rotate(${angle}rad)` : undefined,
};
});
watch(isEditing, async (val) => {
if (val) {
await nextTick();
const textarea = textareaRef.value;
if (textarea) {
textarea.focus();
const len = textarea.value.length;
textarea.setSelectionRange(len, len);
}
}
});
function handleInput(e: Event) {
const target = e.target as HTMLTextAreaElement;
const text = target.value;
const el = editingElement.value;
if (!el) return;
const c = container.value;
const isBound = !!c;
const isArrow = isArrowLabel.value;
if (isBound && !isArrow) {
const maxWidth = getBoundTextMaxWidth(c!);
const metrics = measureText(
text,
el.fontSize,
el.fontFamily,
el.lineHeight,
maxWidth,
);
store.updateElement(el.id, {
originalText: text,
text: metrics.wrappedText,
width: metrics.width,
height: metrics.height,
} as any);
const maxHeight = getBoundTextMaxHeight(c!, el);
if (metrics.height > maxHeight) {
const nextHeight = computeContainerDimensionForBoundText(
metrics.height,
c!.type,
);
store.updateElement(c!.id, { height: nextHeight } as any);
}
const updatedContainer = store.scene.getElement(c!.id) || c!;
const updatedText = store.scene.getElement(el.id) as DrawTextElement;
if (updatedText) {
const pos = computeBoundTextPosition(updatedContainer, {
...updatedText,
width: metrics.width,
height: metrics.height,
} as DrawTextElement, store.scene);
store.updateElement(el.id, { x: pos.x, y: pos.y } as any);
}
} else if (isArrow) {
const metrics = measureText(
text,
el.fontSize,
el.fontFamily,
el.lineHeight,
);
const arrow = c as DrawLinearElement;
const center = getArrowLabelPosition(arrow);
store.updateElement(el.id, {
originalText: text,
text,
width: metrics.width,
height: metrics.height,
x: center.x - metrics.width / 2,
y: center.y - metrics.height / 2,
} as any);
} else {
const metrics = measureText(
text,
el.fontSize,
el.fontFamily,
el.lineHeight,
el.autoResize ? undefined : el.width,
);
const updates: Record<string, any> = {
originalText: text,
text: el.autoResize ? text : metrics.wrappedText,
height: metrics.height,
};
if (el.autoResize) {
updates.width = metrics.width;
}
store.updateElement(el.id, updates as any);
}
store.requestRender();
}
function handleBlur() {
requestAnimationFrame(() => {
if (textareaRef.value && document.activeElement === textareaRef.value) {
return;
}
finishEditing();
});
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') {
e.preventDefault();
finishEditing();
return;
}
if (e.key === 'Enter' && !e.shiftKey && isArrowLabel.value) {
e.preventDefault();
finishEditing();
return;
}
e.stopPropagation();
}
function finishEditing() {
const el = editingElement.value;
if (!el) return;
const c = container.value;
const latestEl = store.scene.getElement(el.id) as DrawTextElement | undefined;
const textContent = latestEl?.originalText ?? el.originalText ?? el.text;
if (!textContent || !textContent.trim()) {
if (c && c.boundElements) {
const newBound = c.boundElements.filter((b) => b.id !== el.id);
store.updateElement(c.id, {
boundElements: newBound.length > 0 ? newBound : null,
} as any);
}
store.scene.deleteElement(el.id);
} else {
if (c) {
const updatedText = (store.scene.getElement(el.id) as DrawTextElement) || el;
redrawTextBoundingBox(updatedText, c, store.scene);
}
store.recordHistory();
}
store.editingTextElement = null;
if (c) {
store.selectElement(c.id);
}
store.requestRender();
}
</script>
<template>
<textarea
v-if="isEditing"
ref="textareaRef"
:value="editingElement?.originalText ?? editingElement?.text ?? ''"
:style="style"
class="zq-draw-text-editor"
spellcheck="false"
wrap="off"
@input="handleInput"
@blur="handleBlur"
@keydown="handleKeyDown"
/>
</template>
<style scoped>
.zq-draw-text-editor {
position: absolute;
z-index: 100;
box-sizing: content-box;
letter-spacing: 0;
}
</style>
@@ -1,71 +0,0 @@
<script setup lang="ts">
import { $t } from '@vben/locales';
import { Undo2, Redo2 } from '@vben/icons';
import { useDrawStore } from '../store/draw-store';
const store = useDrawStore();
</script>
<template>
<div class="zq-draw-undo-redo">
<button
class="zq-draw-undo-btn"
:class="{ 'is-disabled': !store.canUndo }"
:disabled="!store.canUndo"
:title="`${$t('draw.action.undo')} (Ctrl+Z)`"
@click="store.undo()"
>
<Undo2 class="zq-draw-undo-icon" />
</button>
<button
class="zq-draw-undo-btn"
:class="{ 'is-disabled': !store.canRedo }"
:disabled="!store.canRedo"
:title="`${$t('draw.action.redo')} (Ctrl+Y)`"
@click="store.redo()"
>
<Redo2 class="zq-draw-undo-icon" />
</button>
</div>
</template>
<style scoped lang="scss">
.zq-draw-undo-redo {
display: flex;
align-items: center;
gap: 2px;
padding: 4px;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-light);
border-radius: 8px;
box-shadow: 0 2px 8px rgb(0 0 0 / 8%);
}
.zq-draw-undo-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: 6px;
background: transparent;
cursor: pointer;
color: var(--el-text-color-regular);
transition: all 0.15s;
&:hover:not(.is-disabled) {
background: var(--el-fill-color-light);
}
&.is-disabled {
opacity: 0.3;
cursor: not-allowed;
}
}
.zq-draw-undo-icon {
width: 16px;
height: 16px;
}
</style>
@@ -1,104 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue';
import { $t } from '@vben/locales';
import { ZoomIn, ZoomOut, Maximize, ScanSearch } from '@vben/icons';
import { useDrawStore } from '../store/draw-store';
const store = useDrawStore();
const zoomPercentage = computed(() => {
return `${Math.round(store.zoom.value * 100)}%`;
});
</script>
<template>
<div class="zq-draw-zoom-controls">
<button
class="zq-draw-zoom-btn"
:title="$t('draw.action.zoomOut')"
@click="store.zoomOut()"
>
<ZoomOut class="zq-draw-zoom-icon" />
</button>
<button
class="zq-draw-zoom-label"
:title="$t('draw.action.resetZoom')"
@click="store.resetZoom()"
>
{{ zoomPercentage }}
</button>
<button
class="zq-draw-zoom-btn"
:title="$t('draw.action.zoomIn')"
@click="store.zoomIn()"
>
<ZoomIn class="zq-draw-zoom-icon" />
</button>
<button
class="zq-draw-zoom-btn"
:title="$t('draw.action.zoomToFit')"
@click="store.zoomToFit()"
>
<Maximize class="zq-draw-zoom-icon" />
</button>
<button
class="zq-draw-zoom-btn"
:title="$t('draw.action.zoomToFitSelection')"
@click="store.zoomToFitSelection()"
>
<ScanSearch class="zq-draw-zoom-icon" />
</button>
</div>
</template>
<style scoped lang="scss">
.zq-draw-zoom-controls {
display: flex;
align-items: center;
gap: 2px;
padding: 4px;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-light);
border-radius: 8px;
box-shadow: 0 2px 8px rgb(0 0 0 / 8%);
}
.zq-draw-zoom-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: 6px;
background: transparent;
cursor: pointer;
color: var(--el-text-color-regular);
transition: all 0.15s;
&:hover {
background: var(--el-fill-color-light);
}
}
.zq-draw-zoom-label {
min-width: 50px;
height: 32px;
border: none;
border-radius: 6px;
background: transparent;
cursor: pointer;
font-size: 12px;
color: var(--el-text-color-regular);
transition: all 0.15s;
&:hover {
background: var(--el-fill-color-light);
}
}
.zq-draw-zoom-icon {
width: 16px;
height: 16px;
}
</style>
@@ -1,121 +0,0 @@
/**
* SVG icon definitions for the property panel.
* Each icon is a function returning an SVG string for use with v-html.
* Styled after Excalidraw's icon conventions (20x20 or 24x24 viewBox).
*/
// ---------------------------------------------------------------------------
// Fill Style Icons (20x20)
// ---------------------------------------------------------------------------
export const FillHachureIcon = `<svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><g stroke-width="1.25"><path d="M5.879 2.625h8.242a3.254 3.254 0 0 1 3.254 3.254v8.242a3.254 3.254 0 0 1-3.254 3.254H5.88a3.254 3.254 0 0 1-3.254-3.254V5.88a3.254 3.254 0 0 1 3.254-3.254Z"/><path d="M2.258 15.156 15.156 2.258M7.324 20.222 20.222 7.325M-.222 12.675 12.675-.222M4.518 18.118 17.416 5.22"/></g></svg>`;
export const FillCrossHatchIcon = `<svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><g stroke-width="1.25"><path d="M5.879 2.625h8.242a3.254 3.254 0 0 1 3.254 3.254v8.242a3.254 3.254 0 0 1-3.254 3.254H5.88a3.254 3.254 0 0 1-3.254-3.254V5.88a3.254 3.254 0 0 1 3.254-3.254Z"/><path d="M2.258 15.156 15.156 2.258M7.324 20.222 20.222 7.325M-.222 12.675 12.675-.222M4.518 18.118 17.416 5.22"/><path d="m4.844 2.258 12.898 12.898m-5.066 5.066L-.222 7.324m12.897-7.546 7.547 7.546M2.258 17.74l12.898-12.898"/></g></svg>`;
export const FillSolidIcon = `<svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 20 20" fill="currentColor" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><g stroke-width="1.25"><path d="M5.879 2.625h8.242a3.254 3.254 0 0 1 3.254 3.254v8.242a3.254 3.254 0 0 1-3.254 3.254H5.88a3.254 3.254 0 0 1-3.254-3.254V5.88a3.254 3.254 0 0 1 3.254-3.254Z"/></g></svg>`;
export const FillZigZagIcon = `<svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><g stroke-width="1.25"><path d="M5.879 2.625h8.242a3.27 3.27 0 0 1 3.254 3.254v8.242a3.27 3.27 0 0 1-3.254 3.254H5.88a3.27 3.27 0 0 1-3.254-3.254V5.88A3.27 3.27 0 0 1 5.88 2.626ZM4.518 16.118l7.608-12.83m.198 13.934 5.051-9.897M2.778 9.675l9.348-6.387m-7.608 12.83 12.857-8.793"/></g></svg>`;
// ---------------------------------------------------------------------------
// Stroke Width Icons (20x20)
// ---------------------------------------------------------------------------
export const StrokeWidthThinIcon = `<svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M4.167 10h11.666" stroke-width="1.25"/></svg>`;
export const StrokeWidthBoldIcon = `<svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M5 10h10" stroke-width="2.5"/></svg>`;
export const StrokeWidthExtraBoldIcon = `<svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M5 10h10" stroke-width="3.75"/></svg>`;
// ---------------------------------------------------------------------------
// Stroke Style Icons (20x20)
// ---------------------------------------------------------------------------
export const StrokeStyleSolidIcon = `<svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M4.167 10h11.666" stroke-width="1.25"/></svg>`;
export const StrokeStyleDashedIcon = `<svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M5 12h2"/><path d="M17 12h2"/><path d="M11 12h2"/></svg>`;
export const StrokeStyleDottedIcon = `<svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M4 12v.01"/><path d="M8 12v.01"/><path d="M12 12v.01"/><path d="M16 12v.01"/><path d="M20 12v.01"/></svg>`;
// ---------------------------------------------------------------------------
// Sloppiness (Roughness) Icons (20x20)
// ---------------------------------------------------------------------------
export const SloppinessArchitectIcon = `<svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M2.5 12.038c1.655-.885 5.9-3.292 8.568-4.354 2.668-1.063.101 2.821 1.32 3.104 1.218.283 5.112-1.814 5.112-1.814" stroke-width="1.25"/></svg>`;
export const SloppinessArtistIcon = `<svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M2.5 12.563c1.655-.886 5.9-3.293 8.568-4.354 2.668-1.063.101 2.821 1.32 3.104 1.218.283 5.112-1.814 5.112-1.814m-15.086.26c2.31-1.24 6.265-2.952 8.865-3.926 2.6-.975.092 2.588 1.209 2.849 1.118.26 4.688-1.665 4.688-1.665" stroke-width="1.25"/></svg>`;
export const SloppinessCartoonistIcon = `<svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M2.5 11.513c1.655-.886 5.9-3.293 8.568-4.354 2.668-1.063.101 2.821 1.32 3.104 1.218.283 5.112-1.814 5.112-1.814m-15.086.26c2.31-1.24 5.265-1.952 7.865-2.926 2.6-.975 1.092 2.588 2.209 2.849 1.118.26 4.688-1.665 4.688-1.665m-14.676-.027c2.386-1.098 4.79-1.498 7.478-2.26 2.687-.762.066 2.03 1.832 2.675 1.118.26 4.688-1.665 4.688-1.665" stroke-width="1.25"/></svg>`;
// ---------------------------------------------------------------------------
// Edge (Roundness) Icons
// ---------------------------------------------------------------------------
export const EdgeSharpIcon = `<svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"><path d="M3.33 10V6.67c0-.63 0-1.74 0-3.33C4.95 3.33 6.06 3.33 6.67 3.33H10"/><path d="M10 3.33h3.33c.63 0 1.74 0 3.34.01V6.67 10"/><path d="M16.67 10v3.33c0 .63 0 1.74-.01 3.34H13.33 10"/><path d="M10 16.67H6.67c-.63 0-1.74 0-3.34-.01V13.33 10"/></svg>`;
export const EdgeRoundIcon = `<svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"><path d="M4 12v-4a4 4 0 0 1 4-4h4"/><path d="M12 4h4a4 4 0 0 1 4 4v4"/><path d="M20 12v4a4 4 0 0 1-4 4h-4"/><path d="M12 20H8a4 4 0 0 1-4-4v-4"/></svg>`;
// ---------------------------------------------------------------------------
// Arrow Type Icons (24x24)
// ---------------------------------------------------------------------------
export const ArrowTypeSharpIcon = `<svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M6 18l12-12"/><path d="M18 10V6h-4"/></svg>`;
export const ArrowTypeRoundIcon = `<svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M16 12l4-3-4-3"/><path d="M6 20c0-6.075 4.925-11 11-11h3"/></svg>`;
export const ArrowTypeElbowIcon = `<svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M4 19h6c1.097 0 2-.903 2-2V9c0-1.097.903-2 2-2h7"/><path d="M18 4l3 3-3 3"/></svg>`;
// ---------------------------------------------------------------------------
// Arrowhead Icons (40x20)
// ---------------------------------------------------------------------------
export const ArrowheadNoneIcon = `<svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 40 20" fill="none" stroke="currentColor" stroke-linecap="round" stroke-width="2"><path d="M7 11H19" opacity="0.3"/><path d="M25 6L33 16M33 6L25 16" opacity="0.3"/></svg>`;
export const ArrowheadArrowIcon = `<svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 40 20" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M7 11H33M23 5L33 11L23 17"/></svg>`;
export const ArrowheadTriangleIcon = `<svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 40 20" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M7 11H25"/><path d="M25 5L35 11L25 17Z" fill="currentColor"/></svg>`;
export const ArrowheadBarIcon = `<svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 40 20" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M7 11H33"/><path d="M33 5V17"/></svg>`;
export const ArrowheadCircleIcon = `<svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 40 20" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M7 11H24"/><circle cx="30" cy="11" r="5"/></svg>`;
export const ArrowheadDiamondIcon = `<svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 40 20" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M7 11H22"/><path d="M28 3L36 11L28 19L20 11Z"/></svg>`;
// Flipped versions for start arrowheads
export const ArrowheadNoneStartIcon = ArrowheadNoneIcon;
export const ArrowheadArrowStartIcon = `<svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 40 20" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M33 11H7M17 5L7 11L17 17"/></svg>`;
export const ArrowheadTriangleStartIcon = `<svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 40 20" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M33 11H15"/><path d="M15 5L5 11L15 17Z" fill="currentColor"/></svg>`;
export const ArrowheadBarStartIcon = `<svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 40 20" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M33 11H7"/><path d="M7 5V17"/></svg>`;
export const ArrowheadCircleStartIcon = `<svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 40 20" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M33 11H16"/><circle cx="10" cy="11" r="5"/></svg>`;
export const ArrowheadDiamondStartIcon = `<svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 40 20" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M33 11H18"/><path d="M12 3L4 11L12 19L20 11Z"/></svg>`;
// ---------------------------------------------------------------------------
// Font Family Icons (20x20)
// ---------------------------------------------------------------------------
export const FontHandDrawnIcon = `<svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><g stroke-width="1.25"><path clip-rule="evenodd" d="m7.643 15.69 7.774-7.773a2.357 2.357 0 1 0-3.334-3.334L4.31 12.357a3.333 3.333 0 0 0-.977 2.357v1.953h1.953c.884 0 1.732-.352 2.357-.977Z"/><path d="m11.25 5.417 3.333 3.333"/></g></svg>`;
export const FontNormalIcon = `<svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><g stroke-width="1.25"><path d="M5.833 16.667v-10a3.333 3.333 0 0 1 3.334-3.334h1.666a3.333 3.333 0 0 1 3.334 3.334v10"/><path d="M5.833 10.833h8.334"/></g></svg>`;
export const FontCodeIcon = `<svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"><path d="M7 8l-4 4 4 4"/><path d="M17 8l4 4-4 4"/><path d="M14 4l-4 16"/></svg>`;
// ---------------------------------------------------------------------------
// Text Align Icons (24x24)
// ---------------------------------------------------------------------------
export const TextAlignLeftIcon = `<svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><line x1="4" y1="8" x2="20" y2="8"/><line x1="4" y1="12" x2="12" y2="12"/><line x1="4" y1="16" x2="16" y2="16"/></svg>`;
export const TextAlignCenterIcon = `<svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><line x1="4" y1="8" x2="20" y2="8"/><line x1="8" y1="12" x2="16" y2="12"/><line x1="6" y1="16" x2="18" y2="16"/></svg>`;
export const TextAlignRightIcon = `<svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><line x1="4" y1="8" x2="20" y2="8"/><line x1="12" y1="12" x2="20" y2="12"/><line x1="8" y1="16" x2="20" y2="16"/></svg>`;
// Vertical Align (24x24)
export const VerticalAlignTopIcon = `<svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M8 4h8"/><path d="M12 20V8"/><path d="M8 12l4-4 4 4"/></svg>`;
export const VerticalAlignMiddleIcon = `<svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M12 3v5"/><path d="M8 8l4 4 4-4"/><path d="M12 21v-5"/><path d="M8 16l4-4 4 4"/></svg>`;
export const VerticalAlignBottomIcon = `<svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M8 20h8"/><path d="M12 4v12"/><path d="M8 12l4 4 4-4"/></svg>`;
@@ -1,32 +0,0 @@
import { onMounted, onBeforeUnmount, type Ref } from 'vue';
import { useDrawStore } from '../store/draw-store';
export function useCanvasResize(containerRef: Ref<HTMLElement | null>) {
const store = useDrawStore();
let observer: ResizeObserver | null = null;
function updateSize() {
const el = containerRef.value;
if (!el) return;
const rect = el.getBoundingClientRect();
store.canvasWidth = rect.width;
store.canvasHeight = rect.height;
store.offsetTop = rect.top;
store.offsetLeft = rect.left;
store.requestRender();
}
onMounted(() => {
updateSize();
if (containerRef.value) {
observer = new ResizeObserver(() => updateSize());
observer.observe(containerRef.value);
}
});
onBeforeUnmount(() => {
observer?.disconnect();
});
return { updateSize };
}
@@ -1,34 +0,0 @@
import { onMounted, onBeforeUnmount, watch } from 'vue';
import type { DrawData } from '../types';
import { useDrawStore } from '../store/draw-store';
export function useDrawEngine(opts: {
initialData?: DrawData | null;
readonly?: boolean;
}) {
const store = useDrawStore();
onMounted(() => {
store.init();
if (opts.initialData) {
store.loadDrawData(opts.initialData);
}
store.requestRender();
});
watch(
() => opts.readonly,
(val) => {
store.viewModeEnabled = !!val;
store.requestRender();
},
);
onBeforeUnmount(() => {
store.destroy();
});
return { store };
}
@@ -1,294 +0,0 @@
import { onMounted, onBeforeUnmount } from 'vue';
import { useDrawStore } from '../store/draw-store';
import { TOOL_SHORTCUTS } from '../constants';
import type { ToolType, DrawLinearElement, DrawElement } from '../types';
import { deletePoints } from '../elements/linear-element-editor';
import { serializeAsJSON, deserializeFromJSON } from '../data/json';
export function useKeyboard() {
const store = useDrawStore();
function handleKeyDown(e: KeyboardEvent) {
if (store.viewModeEnabled) return;
const target = e.target as HTMLElement;
const isTextInput =
target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.isContentEditable;
if (isTextInput) {
if (e.key === 'Escape' && store.editingTextElement) {
return;
}
return;
}
if (store.editingTextElement) {
if (e.key === 'Escape') {
store.editingTextElement = null;
store.requestRender();
}
return;
}
const ctrl = e.ctrlKey || e.metaKey;
// Open file (Ctrl+O)
if (ctrl && e.key.toLowerCase() === 'o' && !e.shiftKey) {
e.preventDefault();
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json,.excalidraw';
input.onchange = async () => {
const file = input.files?.[0];
if (!file) return;
const text = await file.text();
const data = deserializeFromJSON(text);
if (data) store.loadDrawData(data);
};
input.click();
return;
}
// Save to disk (Ctrl+Shift+S)
if (ctrl && e.key.toLowerCase() === 's' && e.shiftKey) {
e.preventDefault();
const json = serializeAsJSON(
store.scene.getElements(),
store.getAppState(),
store.files,
);
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${store.getAppState().name || 'drawing'}.zqdraw.json`;
a.click();
URL.revokeObjectURL(url);
return;
}
// Undo / Redo
if (ctrl && e.key === 'z' && !e.shiftKey) {
e.preventDefault();
store.undo();
return;
}
if (ctrl && (e.key === 'y' || (e.key.toLowerCase() === 'z' && e.shiftKey))) {
e.preventDefault();
store.redo();
return;
}
// Copy / Cut / Paste (check Alt variants first)
if (ctrl && e.altKey && e.key === 'c') {
e.preventDefault();
store.copyStyle();
return;
}
if (ctrl && e.altKey && e.key === 'v') {
e.preventDefault();
store.pasteStyle();
return;
}
if (ctrl && e.key === 'c' && !e.altKey) {
e.preventDefault();
store.copySelectedElements();
return;
}
if (ctrl && e.key === 'x') {
e.preventDefault();
store.cutSelectedElements();
return;
}
if (ctrl && e.key === 'v' && !e.altKey) {
e.preventDefault();
store.pasteFromClipboard();
return;
}
// Duplicate
if (ctrl && e.key === 'd') {
e.preventDefault();
store.duplicateSelectedElements();
return;
}
// Select All
if (ctrl && e.key === 'a') {
e.preventDefault();
store.selectAll();
return;
}
// Delete (linear editor points or elements)
if (e.key === 'Delete' || e.key === 'Backspace') {
e.preventDefault();
if (store.editingLinearElement && store.editingLinearElement.selectedPointIndices.length > 0) {
const editorState = store.editingLinearElement;
const el = store.scene.getElement(editorState.elementId) as DrawLinearElement | null;
if (el) {
const updated = deletePoints(el, editorState.selectedPointIndices);
if (updated) {
store.recordHistory();
store.updateElement(el.id, updated as Partial<DrawElement>);
editorState.selectedPointIndices = [];
store.requestRender();
}
}
return;
}
store.deleteSelectedElementsWithBindings();
return;
}
// Group / Ungroup
if (ctrl && e.key === 'g' && !e.shiftKey) {
e.preventDefault();
store.groupSelected();
return;
}
if (ctrl && e.key.toLowerCase() === 'g' && e.shiftKey) {
e.preventDefault();
store.ungroupSelected();
return;
}
// Lock / Unlock
if (ctrl && e.key === 'l' && !e.shiftKey) {
e.preventDefault();
if (store.isSelectedLocked) {
store.unlockSelected();
} else {
store.lockSelected();
}
return;
}
// Z-index: Ctrl+] / Ctrl+[
if (ctrl && e.key === ']' && !e.shiftKey) {
e.preventDefault();
store.bringForward();
return;
}
if (ctrl && e.key === '[' && !e.shiftKey) {
e.preventDefault();
store.sendBackward();
return;
}
if (ctrl && (e.key === '}' || (e.key === ']' && e.shiftKey))) {
e.preventDefault();
store.bringToFront();
return;
}
if (ctrl && (e.key === '{' || (e.key === '[' && e.shiftKey))) {
e.preventDefault();
store.sendToBack();
return;
}
// Hyperlink
if (ctrl && e.key === 'k') {
e.preventDefault();
if (store.selectedElements.length === 1) {
store.showHyperlinkPopup = 'editor';
}
return;
}
// Flip
if (e.shiftKey && e.key === 'H' && !ctrl) {
e.preventDefault();
store.flipSelectedHorizontal();
return;
}
if (e.shiftKey && e.key === 'V' && !ctrl) {
e.preventDefault();
store.flipSelectedVertical();
return;
}
// Toggle snap mode (Alt+S)
if (e.altKey && e.key.toLowerCase() === 's') {
e.preventDefault();
store.objectsSnapModeEnabled = !store.objectsSnapModeEnabled;
return;
}
// Toggle stats panel (Alt+I)
if (e.altKey && e.key.toLowerCase() === 'i') {
e.preventDefault();
store.showStats = !store.showStats;
return;
}
// Toggle zen mode (Alt+Z)
if (e.altKey && e.key.toLowerCase() === 'z') {
e.preventDefault();
store.zenModeEnabled = !store.zenModeEnabled;
return;
}
// Escape
if (e.key === 'Escape') {
if (store.editingLinearElement) {
store.editingLinearElement = null;
store.requestRender();
return;
}
store.setActiveTool('selection');
store.clearSelection();
store.newElement = null;
store.selectionElement = null;
store.requestRender();
return;
}
// Zoom
if (ctrl && (e.key === '=' || e.key === '+')) {
e.preventDefault();
store.zoomIn();
return;
}
if (ctrl && e.key === '-') {
e.preventDefault();
store.zoomOut();
return;
}
if (ctrl && e.key === '0') {
e.preventDefault();
store.resetZoom();
return;
}
if (ctrl && e.key === '1' && !e.shiftKey) {
e.preventDefault();
store.zoomToFit();
return;
}
if (ctrl && (e.key === '!' || (e.key === '1' && e.shiftKey))) {
e.preventDefault();
store.zoomToFitSelection();
return;
}
// Tool shortcuts (single key)
if (!ctrl && !e.shiftKey && !e.altKey) {
const key = e.key.toLowerCase();
for (const [tool, shortcut] of Object.entries(TOOL_SHORTCUTS)) {
if (shortcut === key) {
store.setActiveTool(tool as ToolType);
return;
}
}
}
}
onMounted(() => {
document.addEventListener('keydown', handleKeyDown);
});
onBeforeUnmount(() => {
document.removeEventListener('keydown', handleKeyDown);
});
}
File diff suppressed because it is too large Load Diff
@@ -1,52 +0,0 @@
import { onMounted, onBeforeUnmount, type Ref } from 'vue';
import { useDrawStore } from '../store/draw-store';
import { MIN_ZOOM, MAX_ZOOM } from '../constants';
import type { NormalizedZoomValue } from '../types';
export function useScrollWheel(containerRef: Ref<HTMLElement | null>) {
const store = useDrawStore();
function handleWheel(e: WheelEvent) {
e.preventDefault();
if (e.ctrlKey || e.metaKey) {
// zoom
const delta = -e.deltaY;
const factor = delta > 0 ? 1.03 : 0.97;
const newZoom = Math.max(
MIN_ZOOM,
Math.min(MAX_ZOOM, store.zoom.value * factor),
);
const rect = containerRef.value?.getBoundingClientRect();
if (rect) {
const clientX = e.clientX - rect.left;
const clientY = e.clientY - rect.top;
const oldZoom = store.zoom.value;
store.scrollX =
clientX / newZoom - clientX / oldZoom + store.scrollX;
store.scrollY =
clientY / newZoom - clientY / oldZoom + store.scrollY;
}
store.zoom = { value: newZoom as NormalizedZoomValue };
store.requestRender();
} else {
// pan
store.scrollX -= e.deltaX / store.zoom.value;
store.scrollY -= e.deltaY / store.zoom.value;
store.requestRender();
}
}
onMounted(() => {
containerRef.value?.addEventListener('wheel', handleWheel, {
passive: false,
});
});
onBeforeUnmount(() => {
containerRef.value?.removeEventListener('wheel', handleWheel);
});
}
@@ -1,198 +0,0 @@
import type {
Arrowhead,
FillStyle,
NormalizedZoomValue,
Radians,
StrokeStyle,
ToolType,
} from './types';
// ---------------------------------------------------------------------------
// Version
// ---------------------------------------------------------------------------
export const DRAW_VERSION = 1;
export const DRAW_SOURCE = 'zq-draw';
// ---------------------------------------------------------------------------
// Zoom
// ---------------------------------------------------------------------------
export const MIN_ZOOM = 0.1 as NormalizedZoomValue;
export const MAX_ZOOM = 30 as NormalizedZoomValue;
export const DEFAULT_ZOOM = 1 as NormalizedZoomValue;
export const ZOOM_STEP = 0.1;
// ---------------------------------------------------------------------------
// Grid
// ---------------------------------------------------------------------------
export const DEFAULT_GRID_SIZE = 20;
export const DEFAULT_GRID_STEP = 5;
// ---------------------------------------------------------------------------
// Element defaults
// ---------------------------------------------------------------------------
export const DEFAULT_ELEMENT_STROKE_COLOR = '#1e1e1e';
export const DEFAULT_ELEMENT_BACKGROUND_COLOR = 'transparent';
export const DEFAULT_ELEMENT_FILL_STYLE: FillStyle = 'hachure';
export const DEFAULT_ELEMENT_STROKE_WIDTH = 2;
export const DEFAULT_ELEMENT_STROKE_STYLE: StrokeStyle = 'solid';
export const DEFAULT_ELEMENT_ROUGHNESS = 1;
export const DEFAULT_ELEMENT_OPACITY = 100;
export const DEFAULT_ELEMENT_ROUNDNESS = { type: 3 };
// ---------------------------------------------------------------------------
// Text defaults
// ---------------------------------------------------------------------------
export const DEFAULT_FONT_SIZE = 20;
export const DEFAULT_FONT_FAMILY = 5; // Excalifont (hand-drawn)
export const DEFAULT_TEXT_ALIGN = 'left';
export const DEFAULT_VERTICAL_ALIGN = 'top';
export const DEFAULT_LINE_HEIGHT = 1.25;
export const BOUND_TEXT_PADDING = 5;
export const FONT_FAMILY = {
Virgil: 1,
Helvetica: 2,
Cascadia: 3,
Assistant: 4,
Excalifont: 5,
Nunito: 6,
'Comic Shanns': 8,
} as const;
export const FONT_FAMILY_FALLBACKS: Record<number, string> = {
1: '"Virgil", "Excalifont", "Xiaolai", "Segoe UI Emoji", cursive',
2: '"Helvetica", "Segoe UI Emoji", sans-serif',
3: '"Cascadia", "Comic Shanns", "Segoe UI Emoji", monospace',
4: '"Assistant", "Segoe UI Emoji", sans-serif',
5: '"Excalifont", "Virgil", "Xiaolai", "Segoe UI Emoji", cursive',
6: '"Nunito", "Segoe UI Emoji", sans-serif',
8: '"Comic Shanns", "Cascadia", "Segoe UI Emoji", monospace',
};
export interface FontMetrics {
unitsPerEm: number;
ascender: number;
descender: number;
lineHeight: number;
}
export const FONT_METADATA: Record<number, FontMetrics> = {
[FONT_FAMILY.Excalifont]: { unitsPerEm: 1000, ascender: 886, descender: -374, lineHeight: 1.25 },
[FONT_FAMILY.Nunito]: { unitsPerEm: 1000, ascender: 1011, descender: -353, lineHeight: 1.25 },
[FONT_FAMILY['Comic Shanns']]: { unitsPerEm: 1000, ascender: 750, descender: -250, lineHeight: 1.25 },
[FONT_FAMILY.Virgil]: { unitsPerEm: 1000, ascender: 886, descender: -374, lineHeight: 1.25 },
[FONT_FAMILY.Helvetica]: { unitsPerEm: 2048, ascender: 1577, descender: -471, lineHeight: 1.15 },
[FONT_FAMILY.Cascadia]: { unitsPerEm: 2048, ascender: 1900, descender: -480, lineHeight: 1.2 },
[FONT_FAMILY.Assistant]: { unitsPerEm: 2048, ascender: 1021, descender: -287, lineHeight: 1.25 },
};
/**
* Calculates vertical offset for text rendered with alphabetic baseline.
* Matches excalidraw's getVerticalOffset for consistent text positioning.
*/
export function getVerticalOffset(
fontFamily: number,
fontSize: number,
lineHeightPx: number,
): number {
const metrics = FONT_METADATA[fontFamily] ?? FONT_METADATA[FONT_FAMILY.Excalifont]!;
const fontSizeEm = fontSize / metrics.unitsPerEm;
const lineGap = (lineHeightPx - fontSizeEm * metrics.ascender + fontSizeEm * metrics.descender) / 2;
return fontSizeEm * metrics.ascender + lineGap;
}
// ---------------------------------------------------------------------------
// Arrow defaults
// ---------------------------------------------------------------------------
export const DEFAULT_START_ARROWHEAD: Arrowhead | null = null;
export const DEFAULT_END_ARROWHEAD: Arrowhead | null = 'arrow';
// ---------------------------------------------------------------------------
// Colors
// ---------------------------------------------------------------------------
export const CANVAS_BACKGROUND_LIGHT = '#ffffff';
export const CANVAS_BACKGROUND_DARK = '#121212';
export const PRESET_STROKE_COLORS = [
'#1e1e1e',
'#e03131',
'#2f9e44',
'#1971c2',
'#f08c00',
'#6741d9',
'#0c8599',
'#e8590c',
];
export const PRESET_BACKGROUND_COLORS = [
'transparent',
'#ffc9c9',
'#b2f2bb',
'#a5d8ff',
'#ffec99',
'#d0bfff',
'#99e9f2',
'#ffd8a8',
];
// ---------------------------------------------------------------------------
// Snap
// ---------------------------------------------------------------------------
export const SNAP_THRESHOLD = 8;
export const SNAP_LINE_COLOR = '#6366f1';
// ---------------------------------------------------------------------------
// History
// ---------------------------------------------------------------------------
export const HISTORY_MAX_STEPS = 100;
// ---------------------------------------------------------------------------
// Selection
// ---------------------------------------------------------------------------
export const SELECTION_BORDER_COLOR = '#6965db';
export const SELECTION_FILL_COLOR = 'rgba(105, 101, 219, 0.1)';
// ---------------------------------------------------------------------------
// Transform handles
// ---------------------------------------------------------------------------
export const TRANSFORM_HANDLE_SIZE = 8;
export const ROTATION_HANDLE_OFFSET = 24;
// ---------------------------------------------------------------------------
// Misc
// ---------------------------------------------------------------------------
export const ANGLE_ZERO = 0 as Radians;
export const TAU = Math.PI * 2;
export const DRAGGING_THRESHOLD = 3;
export const LINE_CONFIRM_THRESHOLD = 10;
export const MIN_WIDTH_OR_HEIGHT = 1;
// ---------------------------------------------------------------------------
// Tool shortcuts
// ---------------------------------------------------------------------------
export const TOOL_SHORTCUTS: Partial<Record<ToolType, string>> = {
selection: 'v',
rectangle: 'r',
ellipse: 'o',
diamond: 'd',
line: 'l',
arrow: 'a',
freedraw: 'p',
text: 't',
image: 'i',
eraser: 'e',
hand: 'h',
frame: 'f',
};
// ---------------------------------------------------------------------------
// Roundness
// ---------------------------------------------------------------------------
export const DEFAULT_PROPORTIONAL_RADIUS = 0.25;
export const DEFAULT_ADAPTIVE_RADIUS = 32;
export const ROUNDNESS = {
LEGACY: 1,
PROPORTIONAL_RADIUS: 2,
ADAPTIVE_RADIUS: 3,
} as const;
@@ -1,90 +0,0 @@
import type { DrawElement, BinaryFiles } from '../types';
import { duplicateElement } from '../elements/duplicate';
const CLIPBOARD_KEY = 'zq-draw-clipboard';
export interface ClipboardData {
elements: readonly DrawElement[];
files: BinaryFiles;
}
let memoryClipboard: ClipboardData | null = null;
function serializeClipboard(data: ClipboardData): string {
return JSON.stringify({
type: CLIPBOARD_KEY,
elements: data.elements,
files: data.files,
});
}
function deserializeClipboard(text: string): ClipboardData | null {
try {
const parsed = JSON.parse(text);
if (parsed?.type !== CLIPBOARD_KEY || !Array.isArray(parsed.elements)) {
return null;
}
return {
elements: parsed.elements,
files: parsed.files || {},
};
} catch {
return null;
}
}
export async function copyElements(
elements: readonly DrawElement[],
files: BinaryFiles,
): Promise<void> {
const data: ClipboardData = {
elements: elements.map((el) => ({ ...el })),
files: { ...files },
};
memoryClipboard = data;
try {
await navigator.clipboard.writeText(serializeClipboard(data));
} catch {
// fallback to memory only
}
}
export async function cutElements(
elements: readonly DrawElement[],
files: BinaryFiles,
): Promise<void> {
await copyElements(elements, files);
}
export async function pasteElements(
offset: { x: number; y: number } = { x: 10, y: 10 },
): Promise<ClipboardData | null> {
let source: ClipboardData | null = null;
try {
const text = await navigator.clipboard.readText();
source = deserializeClipboard(text);
} catch {
// system clipboard not available
}
if (!source) {
source = memoryClipboard;
}
if (!source) return null;
const duplicated = source.elements.map((el) =>
duplicateElement(el, offset.x, offset.y),
);
return {
elements: duplicated,
files: { ...source.files },
};
}
export function hasClipboardData(): boolean {
return memoryClipboard !== null;
}
@@ -1,38 +0,0 @@
import type { ToolType } from '../types';
const TOOL_CURSORS: Record<string, string> = {
selection: 'default',
hand: 'grab',
freedraw: 'crosshair',
text: 'text',
eraser: 'crosshair',
laser: 'crosshair',
};
const SHAPE_CURSOR = 'crosshair';
export function getCursorForTool(tool: ToolType): string {
return TOOL_CURSORS[tool] ?? SHAPE_CURSOR;
}
export function getGrabbingCursor(): string {
return 'grabbing';
}
export function getResizeCursor(angle: number, direction: string): string {
const directionMap: Record<string, string> = {
n: 'ns-resize',
s: 'ns-resize',
e: 'ew-resize',
w: 'ew-resize',
ne: 'nesw-resize',
sw: 'nesw-resize',
nw: 'nwse-resize',
se: 'nwse-resize',
};
return directionMap[direction] ?? 'default';
}
export function getRotationCursor(): string {
return 'grab';
}
@@ -1,132 +0,0 @@
import type { DrawElement, GlobalPoint, NonDeletedDrawElement } from '../types';
import { getElementBounds } from '../elements/bounds';
import { pointDistance } from '../math/point';
import type { Point } from '../math/types';
const ERASER_RADIUS = 10;
export class EraserTrail {
private points: GlobalPoint[] = [];
private erasedIds = new Set<string>();
addPoint(
x: number,
y: number,
elements: readonly NonDeletedDrawElement[],
zoom: number,
): Set<string> {
const point: GlobalPoint = [x, y] as GlobalPoint;
this.points.push(point);
if (this.points.length < 2) return this.erasedIds;
const prev = this.points[this.points.length - 2]!;
const curr = this.points[this.points.length - 1]!;
const radius = ERASER_RADIUS / zoom;
for (const el of elements) {
if (this.erasedIds.has(el.id) || el.locked) continue;
if (el.type === 'selection') continue;
if (this.testElement([prev, curr], el, radius)) {
this.erasedIds.add(el.id);
this.addRelatedElements(el, elements);
}
}
return this.erasedIds;
}
private testElement(
segment: [GlobalPoint, GlobalPoint],
element: DrawElement,
radius: number,
): boolean {
const [x1, y1, x2, y2] = getElementBounds(element);
const expandedBounds = [
x1 - radius,
y1 - radius,
x2 + radius,
y2 + radius,
];
if (!this.segmentIntersectsBounds(segment, expandedBounds)) {
return false;
}
const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2;
const dist = distanceToSegment(
[cx, cy] as Point,
[segment[0][0], segment[0][1]] as Point,
[segment[1][0], segment[1][1]] as Point,
);
const elementRadius = Math.max(x2 - x1, y2 - y1) / 2;
return dist <= elementRadius + radius;
}
private segmentIntersectsBounds(
segment: [GlobalPoint, GlobalPoint],
bounds: number[],
): boolean {
const [sx1, sy1] = segment[0];
const [sx2, sy2] = segment[1];
const [bx1, by1, bx2, by2] = bounds;
const minSx = Math.min(sx1, sx2);
const maxSx = Math.max(sx1, sx2);
const minSy = Math.min(sy1, sy2);
const maxSy = Math.max(sy1, sy2);
return maxSx >= bx1! && minSx <= bx2! && maxSy >= by1! && minSy <= by2!;
}
private addRelatedElements(
element: DrawElement,
elements: readonly NonDeletedDrawElement[],
): void {
if (element.groupIds.length > 0) {
for (const el of elements) {
for (const gid of element.groupIds) {
if (el.groupIds.includes(gid)) {
this.erasedIds.add(el.id);
}
}
}
}
if (element.boundElements) {
for (const bound of element.boundElements) {
this.erasedIds.add(bound.id);
}
}
}
getErasedIds(): Set<string> {
return this.erasedIds;
}
clear(): void {
this.points = [];
this.erasedIds.clear();
}
}
function distanceToSegment(p: Point, a: Point, b: Point): number {
const dx = b[0] - a[0];
const dy = b[1] - a[1];
const lenSq = dx * dx + dy * dy;
if (lenSq === 0) {
return pointDistance(p, a);
}
let t = ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / lenSq;
t = Math.max(0, Math.min(1, t));
const projX = a[0] + t * dx;
const projY = a[1] + t * dy;
return pointDistance(p, [projX, projY]);
}
@@ -1,39 +0,0 @@
export interface GestureState {
pointers: Map<number, { x: number; y: number }>;
lastCenter: { x: number; y: number } | null;
initialDistance: number | null;
initialScale: number | null;
}
export function createGestureState(): GestureState {
return {
pointers: new Map(),
lastCenter: null,
initialDistance: null,
initialScale: null,
};
}
export function getGestureCenter(
state: GestureState,
): { x: number; y: number } | null {
if (state.pointers.size < 2) return null;
const points = Array.from(state.pointers.values());
let cx = 0;
let cy = 0;
for (const p of points) {
cx += p.x;
cy += p.y;
}
return { x: cx / points.length, y: cy / points.length };
}
export function getGestureDistance(state: GestureState): number | null {
if (state.pointers.size < 2) return null;
const points = Array.from(state.pointers.values());
const dx = points[1]!.x - points[0]!.x;
const dy = points[1]!.y - points[0]!.y;
return Math.hypot(dx, dy);
}
@@ -1,55 +0,0 @@
import type { DrawElement, AppState } from '../types';
import { HISTORY_MAX_STEPS } from '../constants';
export interface HistoryEntry {
elements: readonly DrawElement[];
appState: Pick<
AppState,
| 'selectedElementIds'
| 'selectedGroupIds'
| 'viewBackgroundColor'
| 'editingGroupId'
| 'editingTextElement'
| 'name'
>;
}
export class History {
private undoStack: HistoryEntry[] = [];
private redoStack: HistoryEntry[] = [];
get canUndo(): boolean {
return this.undoStack.length > 0;
}
get canRedo(): boolean {
return this.redoStack.length > 0;
}
record(entry: HistoryEntry): void {
this.undoStack.push(entry);
if (this.undoStack.length > HISTORY_MAX_STEPS) {
this.undoStack.shift();
}
this.redoStack = [];
}
undo(currentEntry: HistoryEntry): HistoryEntry | null {
const entry = this.undoStack.pop();
if (!entry) return null;
this.redoStack.push(currentEntry);
return entry;
}
redo(currentEntry: HistoryEntry): HistoryEntry | null {
const entry = this.redoStack.pop();
if (!entry) return null;
this.undoStack.push(currentEntry);
return entry;
}
clear(): void {
this.undoStack = [];
this.redoStack = [];
}
}
@@ -1,78 +0,0 @@
import type { AppState, NormalizedZoomValue } from '../../types';
export function sceneCoordsToViewport(
sceneX: number,
sceneY: number,
appState: { scrollX: number; scrollY: number; zoom: { value: number } },
): { x: number; y: number } {
return {
x: (sceneX + appState.scrollX) * appState.zoom.value,
y: (sceneY + appState.scrollY) * appState.zoom.value,
};
}
export function viewportCoordsToScene(
viewportX: number,
viewportY: number,
appState: { scrollX: number; scrollY: number; zoom: { value: number } },
): { x: number; y: number } {
return {
x: viewportX / appState.zoom.value - appState.scrollX,
y: viewportY / appState.zoom.value - appState.scrollY,
};
}
export function getNormalizedZoom(zoom: number): NormalizedZoomValue {
return Math.max(0.1, Math.min(30, zoom)) as NormalizedZoomValue;
}
export function applyZoom(
ctx: CanvasRenderingContext2D,
appState: { scrollX: number; scrollY: number; zoom: { value: number } },
): void {
const dpr = window.devicePixelRatio || 1;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.scale(appState.zoom.value, appState.zoom.value);
ctx.translate(appState.scrollX, appState.scrollY);
}
export function drawGrid(
ctx: CanvasRenderingContext2D,
appState: AppState,
): void {
if (!appState.gridModeEnabled) return;
const gridSize = appState.gridSize;
const zoom = appState.zoom.value;
const offsetX = appState.scrollX * zoom;
const offsetY = appState.scrollY * zoom;
const width = appState.width;
const height = appState.height;
ctx.save();
const dprGrid = window.devicePixelRatio || 1;
ctx.setTransform(dprGrid, 0, 0, dprGrid, 0, 0);
ctx.strokeStyle = appState.theme === 'dark'
? 'rgba(255, 255, 255, 0.1)'
: 'rgba(0, 0, 0, 0.1)';
ctx.lineWidth = 1;
const scaledGrid = gridSize * zoom;
const startX = (offsetX % scaledGrid) - scaledGrid;
const startY = (offsetY % scaledGrid) - scaledGrid;
ctx.beginPath();
for (let x = startX; x < width + scaledGrid; x += scaledGrid) {
ctx.moveTo(Math.round(x) + 0.5, 0);
ctx.lineTo(Math.round(x) + 0.5, height);
}
for (let y = startY; y < height + scaledGrid; y += scaledGrid) {
ctx.moveTo(0, Math.round(y) + 0.5);
ctx.lineTo(width, Math.round(y) + 0.5);
}
ctx.stroke();
ctx.restore();
}
@@ -1,408 +0,0 @@
import rough from 'roughjs';
import type {
DrawElement,
AppState,
NonDeletedDrawElement,
SuggestedBinding,
GlobalPoint,
} from '../../types';
import { getCommonBounds, getLinearElementLocalBounds } from '../../elements/bounds';
import {
getTransformHandles,
getLinearPointHandles,
getLinearMidpointHandles,
} from '../../elements/transform-handles';
import { drawElementOnCanvas } from '../../elements/shape-generator';
import { getElementSnapPoints } from '../../elements/binding';
import { applyZoom } from './helpers';
import {
SELECTION_BORDER_COLOR,
SELECTION_FILL_COLOR,
SNAP_LINE_COLOR,
} from '../../constants';
export function renderInteractiveScene(
canvas: HTMLCanvasElement,
elements: readonly NonDeletedDrawElement[],
appState: AppState,
): void {
const ctx = canvas.getContext('2d');
if (!ctx) return;
const dpr = window.devicePixelRatio || 1;
canvas.width = appState.width * dpr;
canvas.height = appState.height * dpr;
canvas.style.width = `${appState.width}px`;
canvas.style.height = `${appState.height}px`;
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, appState.width, appState.height);
applyZoom(ctx, appState);
// draw selection element (rubber band)
if (appState.selectionElement) {
const sel = appState.selectionElement;
ctx.save();
ctx.strokeStyle = SELECTION_BORDER_COLOR;
ctx.fillStyle = SELECTION_FILL_COLOR;
ctx.lineWidth = 1 / appState.zoom.value;
ctx.fillRect(sel.x, sel.y, sel.width, sel.height);
ctx.strokeRect(sel.x, sel.y, sel.width, sel.height);
ctx.restore();
}
// draw new element being created (render actual shape, not just outline)
if (appState.newElement && !appState.newElement.isDeleted) {
const rc = rough.canvas(canvas);
drawElementOnCanvas(rc, ctx, appState.newElement);
}
// draw selection highlights (skip bound text elements)
const selectedElements = elements.filter(
(el) => appState.selectedElementIds[el.id]
&& !(el.type === 'text' && (el as any).containerId),
);
if (selectedElements.length > 0) {
drawSelectionHighlights(ctx, selectedElements, appState);
}
// draw snap lines
if (appState.snapLines.length > 0) {
drawSnapLines(ctx, appState);
}
// draw binding highlights
if (appState.suggestedBindings.length > 0) {
drawBindingHighlights(ctx, appState.suggestedBindings, appState);
}
}
function strokeRectWithRotation(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
width: number,
height: number,
cx: number,
cy: number,
angle: number,
): void {
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(angle);
ctx.strokeRect(x - cx, y - cy, width, height);
ctx.restore();
}
function drawRoundedHandle(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
width: number,
height: number,
zoom: number,
): void {
const radius = 2 / zoom;
if (ctx.roundRect) {
ctx.beginPath();
ctx.roundRect(x, y, width, height, radius);
ctx.fill();
ctx.stroke();
} else {
ctx.fillRect(x, y, width, height);
ctx.strokeRect(x, y, width, height);
}
}
function isLinearElement(el: DrawElement): boolean {
return el.type === 'line' || el.type === 'arrow';
}
function hasBoundingBox(
el: DrawElement,
appState: AppState,
): boolean {
if (appState.editingLinearElement) {
return false;
}
if (!isLinearElement(el)) {
return true;
}
return (el as any).points?.length > 2;
}
function drawSelectionHighlights(
ctx: CanvasRenderingContext2D,
selectedElements: readonly DrawElement[],
appState: AppState,
): void {
const zoom = appState.zoom.value;
const lineWidth = 1 / zoom;
const padding = 4 / zoom;
if (selectedElements.length === 1) {
const el = selectedElements[0]!;
const isLinear = isLinearElement(el);
const showBBox = hasBoundingBox(el, appState);
let selX: number, selY: number, selW: number, selH: number;
let selCx: number, selCy: number, selAngle: number;
if (isLinear) {
const [bx1, by1, bx2, by2] = getLinearElementLocalBounds(el as any);
selX = bx1;
selY = by1;
selW = bx2 - bx1;
selH = by2 - by1;
selCx = el.x + el.width / 2;
selCy = el.y + el.height / 2;
selAngle = el.angle;
} else {
selX = el.x;
selY = el.y;
selW = el.width;
selH = el.height;
selCx = el.x + el.width / 2;
selCy = el.y + el.height / 2;
selAngle = el.angle;
}
ctx.save();
ctx.strokeStyle = SELECTION_BORDER_COLOR;
ctx.lineWidth = lineWidth;
if (showBBox) {
strokeRectWithRotation(
ctx,
selX - padding,
selY - padding,
selW + padding * 2,
selH + padding * 2,
selCx,
selCy,
selAngle,
);
}
if (!appState.viewModeEnabled) {
if (isLinear) {
const isEditing = !!appState.editingLinearElement;
drawLinearPointHandles(ctx, el, zoom, lineWidth, isEditing);
}
if (showBBox) {
const handles = getTransformHandles(el, zoom);
for (const handle of handles) {
ctx.fillStyle = '#ffffff';
ctx.strokeStyle = SELECTION_BORDER_COLOR;
ctx.lineWidth = lineWidth;
if (handle.type === 'rotation') {
ctx.beginPath();
ctx.arc(
handle.x + handle.width / 2,
handle.y + handle.height / 2,
handle.width / 2,
0,
Math.PI * 2,
);
ctx.fill();
ctx.stroke();
} else {
drawRoundedHandle(ctx, handle.x, handle.y, handle.width, handle.height, zoom);
}
}
}
}
ctx.restore();
} else if (selectedElements.length > 1) {
const [x1, y1, x2, y2] = getCommonBounds(selectedElements);
ctx.save();
ctx.strokeStyle = SELECTION_BORDER_COLOR;
ctx.lineWidth = lineWidth;
ctx.setLineDash([5 / zoom, 5 / zoom]);
ctx.strokeRect(
x1 - padding,
y1 - padding,
x2 - x1 + padding * 2,
y2 - y1 + padding * 2,
);
for (const el of selectedElements) {
const { x: ex, y: ey, width: ew, height: eh, angle: ea } = el;
const ecx = ex + ew / 2;
const ecy = ey + eh / 2;
strokeRectWithRotation(ctx, ex, ey, ew, eh, ecx, ecy, ea);
}
ctx.restore();
}
}
function drawLinearPointHandles(
ctx: CanvasRenderingContext2D,
element: DrawElement,
zoom: number,
lineWidth: number,
isEditing: boolean,
): void {
const pointHandles = getLinearPointHandles(element, zoom);
const pointCount = (element as any).points?.length ?? 0;
const radius = isEditing
? pointHandles[0]?.size ?? 8 / zoom
: (pointHandles[0]?.size ?? 8 / zoom) / 2;
for (const h of pointHandles) {
ctx.fillStyle = '#ffffff';
ctx.strokeStyle = SELECTION_BORDER_COLOR;
ctx.lineWidth = lineWidth;
ctx.beginPath();
ctx.arc(h.x, h.y, radius / 2, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
}
const showMidpoints = isEditing || pointCount === 2;
if (showMidpoints) {
const midHandles = getLinearMidpointHandles(element, zoom);
for (const h of midHandles) {
ctx.fillStyle = 'rgba(177, 151, 252, 0.7)';
ctx.strokeStyle = SELECTION_BORDER_COLOR;
ctx.lineWidth = lineWidth;
ctx.beginPath();
ctx.arc(h.x, h.y, h.size / 2, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
}
}
}
function drawSnapLines(
ctx: CanvasRenderingContext2D,
appState: AppState,
): void {
ctx.save();
ctx.strokeStyle = SNAP_LINE_COLOR;
ctx.lineWidth = 1 / appState.zoom.value;
ctx.setLineDash([4 / appState.zoom.value, 4 / appState.zoom.value]);
for (const snapLine of appState.snapLines) {
if (snapLine.points.length >= 2) {
ctx.beginPath();
ctx.moveTo(snapLine.points[0]![0], snapLine.points[0]![1]);
for (let i = 1; i < snapLine.points.length; i++) {
ctx.lineTo(snapLine.points[i]![0], snapLine.points[i]![1]);
}
ctx.stroke();
}
}
ctx.restore();
}
function drawBindingHighlights(
ctx: CanvasRenderingContext2D,
suggestedBindings: SuggestedBinding[],
appState: AppState,
): void {
const zoom = appState.zoom.value;
for (const suggestion of suggestedBindings) {
const el = suggestion.element;
drawBindingOutline(ctx, el, zoom);
drawBindingSnapPoints(ctx, el, suggestion.midPoint, zoom);
}
}
function drawBindingOutline(
ctx: CanvasRenderingContext2D,
element: DrawElement,
zoom: number,
): void {
const { x, y, width, height, angle } = element;
const cx = x + width / 2;
const cy = y + height / 2;
const lineWidth = 2 / zoom;
ctx.save();
ctx.strokeStyle = SELECTION_BORDER_COLOR;
ctx.lineWidth = lineWidth;
ctx.globalAlpha = 0.6;
ctx.translate(cx, cy);
ctx.rotate(angle);
const lx = x - cx;
const ly = y - cy;
if (element.type === 'ellipse') {
ctx.beginPath();
ctx.ellipse(0, 0, width / 2, height / 2, 0, 0, Math.PI * 2);
ctx.stroke();
} else if (element.type === 'diamond') {
ctx.beginPath();
ctx.moveTo(0, ly);
ctx.lineTo(lx + width, 0);
ctx.lineTo(0, ly + height);
ctx.lineTo(lx, 0);
ctx.closePath();
ctx.stroke();
} else {
const r = element.roundness ? Math.min(width, height) * 0.1 : 0;
if (r > 0 && ctx.roundRect) {
ctx.beginPath();
ctx.roundRect(lx, ly, width, height, r);
ctx.stroke();
} else {
ctx.strokeRect(lx, ly, width, height);
}
}
ctx.restore();
}
function drawBindingSnapPoints(
ctx: CanvasRenderingContext2D,
element: DrawElement,
activeMidPoint: GlobalPoint | null,
zoom: number,
): void {
const snapPoints = getElementSnapPoints(element);
const dotRadius = 4 / zoom;
const activeDotRadius = 6 / zoom;
for (const sp of snapPoints) {
const isActive =
activeMidPoint &&
Math.abs(sp[0] - activeMidPoint[0]) < 0.5 &&
Math.abs(sp[1] - activeMidPoint[1]) < 0.5;
ctx.save();
if (isActive) {
ctx.fillStyle = SELECTION_BORDER_COLOR;
ctx.globalAlpha = 1;
ctx.beginPath();
ctx.arc(sp[0], sp[1], activeDotRadius, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 1.5 / zoom;
ctx.stroke();
} else {
ctx.fillStyle = '#ffffff';
ctx.strokeStyle = SELECTION_BORDER_COLOR;
ctx.lineWidth = 1 / zoom;
ctx.globalAlpha = 0.7;
ctx.beginPath();
ctx.arc(sp[0], sp[1], dotRadius, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
}
ctx.restore();
}
}
@@ -1,216 +0,0 @@
import rough from 'roughjs';
import type { DrawElement, DrawTextElement, DrawFrameElement, AppState, BinaryFiles } from '../../types';
import { drawElementOnCanvas } from '../../elements/shape-generator';
import { applyZoom, drawGrid } from './helpers';
import { FONT_FAMILY_FALLBACKS, SELECTION_BORDER_COLOR, getVerticalOffset } from '../../constants';
import { getLineHeightInPx, BOUND_TEXT_PADDING } from '../../elements/bound-text';
export function renderStaticScene(
canvas: HTMLCanvasElement,
elements: readonly DrawElement[],
appState: AppState,
files: BinaryFiles,
imageCache: Map<string, HTMLImageElement>,
): void {
const ctx = canvas.getContext('2d');
if (!ctx) return;
const rc = rough.canvas(canvas);
const dpr = window.devicePixelRatio || 1;
canvas.width = appState.width * dpr;
canvas.height = appState.height * dpr;
canvas.style.width = `${appState.width}px`;
canvas.style.height = `${appState.height}px`;
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, appState.width, appState.height);
if (appState.viewBackgroundColor) {
ctx.fillStyle = appState.viewBackgroundColor;
ctx.fillRect(0, 0, appState.width, appState.height);
}
drawGrid(ctx, appState);
applyZoom(ctx, appState);
const frameMap = new Map<string, DrawFrameElement>();
for (const element of elements) {
if (element.type === 'frame' && !element.isDeleted) {
frameMap.set(element.id, element as DrawFrameElement);
}
}
const editingTextId = appState.editingTextElement?.id ?? null;
const linearContainerIds = new Set<string>();
for (const element of elements) {
if (element.isDeleted) continue;
if (element.type === 'arrow' || element.type === 'line') {
if (element.boundElements?.some((b) => b.type === 'text')) {
linearContainerIds.add(element.id);
}
}
}
for (const element of elements) {
if (element.isDeleted) continue;
if (element.type === 'text' && element.id === editingTextId) continue;
const frame = element.frameId ? frameMap.get(element.frameId) : null;
const shouldClip = frame && appState.frameRendering.enabled && appState.frameRendering.clip;
if (shouldClip && frame) {
ctx.save();
ctx.beginPath();
ctx.rect(frame.x, frame.y, frame.width, frame.height);
ctx.clip();
}
if (element.type === 'frame') {
renderFrameElement(ctx, element as DrawFrameElement, appState);
} else if (element.type === 'text') {
const textEl = element as DrawTextElement;
if (textEl.containerId && linearContainerIds.has(textEl.containerId)) {
renderLinearBoundText(ctx, textEl, appState.viewBackgroundColor);
} else {
renderTextElement(ctx, textEl);
}
} else if (element.type === 'image') {
renderImageElement(ctx, element, imageCache);
} else {
drawElementOnCanvas(rc, ctx, element);
}
if (shouldClip) {
ctx.restore();
}
}
}
function renderLinearBoundText(
ctx: CanvasRenderingContext2D,
element: DrawTextElement,
bgColor: string | null,
): void {
if (!element.text) return;
const pad = BOUND_TEXT_PADDING;
ctx.save();
ctx.globalAlpha = element.opacity / 100;
if (bgColor) {
ctx.fillStyle = bgColor;
ctx.fillRect(
element.x - pad,
element.y - pad,
element.width + pad * 2,
element.height + pad * 2,
);
}
ctx.restore();
renderTextElement(ctx, element);
}
function renderTextElement(
ctx: CanvasRenderingContext2D,
element: DrawTextElement,
): void {
if (!element.text) return;
ctx.save();
ctx.translate(element.x, element.y);
if (element.angle !== 0) {
const cx = element.width / 2;
const cy = element.height / 2;
ctx.translate(cx, cy);
ctx.rotate(element.angle);
ctx.translate(-cx, -cy);
}
ctx.globalAlpha = element.opacity / 100;
const fontFamily = FONT_FAMILY_FALLBACKS[element.fontFamily] ?? 'sans-serif';
ctx.font = `${element.fontSize}px ${fontFamily}`;
ctx.fillStyle = element.strokeColor;
ctx.textAlign = element.textAlign as CanvasTextAlign;
const lines = element.text.split('\n');
const lineHeightPx = getLineHeightInPx(element.fontSize, element.lineHeight);
let textX = 0;
if (element.textAlign === 'center') {
textX = element.width / 2;
} else if (element.textAlign === 'right') {
textX = element.width;
}
const vOffset = getVerticalOffset(element.fontFamily, element.fontSize, lineHeightPx);
for (let i = 0; i < lines.length; i++) {
const line = lines[i]!;
ctx.fillText(line, textX, i * lineHeightPx + vOffset);
}
ctx.restore();
}
function renderFrameElement(
ctx: CanvasRenderingContext2D,
element: DrawFrameElement,
appState: AppState,
): void {
if (!appState.frameRendering.enabled) return;
ctx.save();
ctx.globalAlpha = element.opacity / 100;
if (appState.frameRendering.outline) {
ctx.strokeStyle = SELECTION_BORDER_COLOR;
ctx.lineWidth = 2;
ctx.setLineDash([8, 4]);
ctx.strokeRect(element.x, element.y, element.width, element.height);
ctx.setLineDash([]);
}
if (appState.frameRendering.name && element.name) {
const zoom = appState.zoom.value;
const fontSize = 12 / zoom;
ctx.font = `${fontSize}px sans-serif`;
ctx.fillStyle = SELECTION_BORDER_COLOR;
ctx.textAlign = 'left';
ctx.textBaseline = 'bottom';
ctx.fillText(element.name, element.x, element.y - 4 / zoom);
}
ctx.restore();
}
function renderImageElement(
ctx: CanvasRenderingContext2D,
element: DrawElement,
imageCache: Map<string, HTMLImageElement>,
): void {
const imgEl = element as DrawElement & { fileId: string | null };
if (!imgEl.fileId) return;
const img = imageCache.get(imgEl.fileId);
if (!img) return;
ctx.save();
ctx.translate(element.x, element.y);
if (element.angle !== 0) {
const cx = element.width / 2;
const cy = element.height / 2;
ctx.translate(cx, cy);
ctx.rotate(element.angle);
ctx.translate(-cx, -cy);
}
ctx.globalAlpha = element.opacity / 100;
ctx.drawImage(img, 0, 0, element.width, element.height);
ctx.restore();
}
@@ -1,142 +0,0 @@
import type {
DrawElement,
NonDeletedDrawElement,
AppState,
} from '../types';
type SceneCallback = () => void;
export class Scene {
private elementsMap = new Map<string, DrawElement>();
private nonDeletedElements: NonDeletedDrawElement[] = [];
private version = 0;
private callbacks = new Set<SceneCallback>();
getVersion(): number {
return this.version;
}
getElements(): readonly DrawElement[] {
return Array.from(this.elementsMap.values());
}
getElementsIncludingDeleted(): readonly DrawElement[] {
return Array.from(this.elementsMap.values());
}
getNonDeletedElements(): readonly NonDeletedDrawElement[] {
return this.nonDeletedElements;
}
getElementsMapIncludingDeleted(): Map<string, DrawElement> {
return this.elementsMap;
}
getElement(id: string): DrawElement | undefined {
return this.elementsMap.get(id);
}
replaceAllElements(elements: readonly DrawElement[]): void {
this.elementsMap.clear();
for (const el of elements) {
this.elementsMap.set(el.id, el);
}
this.updateNonDeleted();
this.version++;
this.triggerCallbacks();
}
insertElement(element: DrawElement): void {
this.elementsMap.set(element.id, element);
this.updateNonDeleted();
this.version++;
this.triggerCallbacks();
}
insertElements(elements: readonly DrawElement[]): void {
for (const el of elements) {
this.elementsMap.set(el.id, el);
}
this.updateNonDeleted();
this.version++;
this.triggerCallbacks();
}
mutateElement(
id: string,
updates: Partial<DrawElement>,
): DrawElement | null {
const el = this.elementsMap.get(id);
if (!el) return null;
const updated = {
...el,
...updates,
version: el.version + 1,
versionNonce: randomInteger(),
updated: Date.now(),
} as DrawElement;
this.elementsMap.set(id, updated);
this.updateNonDeleted();
this.version++;
this.triggerCallbacks();
return updated;
}
deleteElement(id: string): void {
const el = this.elementsMap.get(id);
if (!el) return;
this.elementsMap.set(id, { ...el, isDeleted: true } as DrawElement);
this.updateNonDeleted();
this.version++;
this.triggerCallbacks();
}
deleteElements(ids: string[]): void {
for (const id of ids) {
const el = this.elementsMap.get(id);
if (el) {
this.elementsMap.set(id, { ...el, isDeleted: true } as DrawElement);
}
}
this.updateNonDeleted();
this.version++;
this.triggerCallbacks();
}
getSelectedElements(appState: AppState): NonDeletedDrawElement[] {
return this.nonDeletedElements.filter(
(el) => appState.selectedElementIds[el.id],
);
}
onChange(callback: SceneCallback): () => void {
this.callbacks.add(callback);
return () => {
this.callbacks.delete(callback);
};
}
destroy(): void {
this.elementsMap.clear();
this.nonDeletedElements = [];
this.callbacks.clear();
}
private updateNonDeleted(): void {
this.nonDeletedElements = Array.from(this.elementsMap.values()).filter(
(el): el is NonDeletedDrawElement => !el.isDeleted,
);
}
private triggerCallbacks(): void {
for (const cb of this.callbacks) {
cb();
}
}
}
function randomInteger(): number {
return Math.floor(Math.random() * 2 ** 31);
}
@@ -1,475 +0,0 @@
import type { DrawElement, GlobalPoint, SnapLine } from '../types';
import type { Bounds } from '../math/types';
import { getElementBounds, getCommonBounds } from '../elements/bounds';
import { SNAP_THRESHOLD } from '../constants';
interface SnapResult {
snappedOffset: { x: number; y: number };
snapLines: SnapLine[];
}
interface ReferencePoint {
x: number;
y: number;
elementId: string;
}
interface Gap {
axis: 'x' | 'y';
gap: number;
startBounds: Bounds;
endBounds: Bounds;
overlap: [number, number];
}
export class SnapCache {
private referencePoints: ReferencePoint[] = [];
private visibleGaps: Gap[] = [];
private dirty = true;
invalidate(): void {
this.dirty = true;
}
update(
elements: readonly DrawElement[],
excludeIds: Set<string>,
): void {
if (!this.dirty) return;
this.referencePoints = [];
this.visibleGaps = [];
const filtered = elements.filter(
(el) => !el.isDeleted && !excludeIds.has(el.id) && el.type !== 'selection',
);
for (const el of filtered) {
const [x1, y1, x2, y2] = getElementBounds(el);
const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2;
// corners
this.referencePoints.push(
{ x: x1, y: y1, elementId: el.id },
{ x: x2, y: y1, elementId: el.id },
{ x: x2, y: y2, elementId: el.id },
{ x: x1, y: y2, elementId: el.id },
);
// edge midpoints
this.referencePoints.push(
{ x: cx, y: y1, elementId: el.id },
{ x: x2, y: cy, elementId: el.id },
{ x: cx, y: y2, elementId: el.id },
{ x: x1, y: cy, elementId: el.id },
);
// center
this.referencePoints.push(
{ x: cx, y: cy, elementId: el.id },
);
}
this.computeGaps(filtered);
this.dirty = false;
}
private computeGaps(elements: readonly DrawElement[]): void {
if (elements.length < 2) return;
const boundsArr = elements.map((el) => ({
id: el.id,
bounds: getElementBounds(el),
}));
// horizontal gaps (sorted by left edge)
const sortedByX = [...boundsArr].sort((a, b) => a.bounds[0] - b.bounds[0]);
for (let i = 0; i < sortedByX.length - 1; i++) {
const a = sortedByX[i]!;
const b = sortedByX[i + 1]!;
const gap = b.bounds[0] - a.bounds[2];
if (gap > 0) {
const overlapStart = Math.max(a.bounds[1], b.bounds[1]);
const overlapEnd = Math.min(a.bounds[3], b.bounds[3]);
if (overlapEnd > overlapStart) {
this.visibleGaps.push({
axis: 'x',
gap,
startBounds: a.bounds,
endBounds: b.bounds,
overlap: [overlapStart, overlapEnd],
});
}
}
}
// vertical gaps (sorted by top edge)
const sortedByY = [...boundsArr].sort((a, b) => a.bounds[1] - b.bounds[1]);
for (let i = 0; i < sortedByY.length - 1; i++) {
const a = sortedByY[i]!;
const b = sortedByY[i + 1]!;
const gap = b.bounds[1] - a.bounds[3];
if (gap > 0) {
const overlapStart = Math.max(a.bounds[0], b.bounds[0]);
const overlapEnd = Math.min(a.bounds[2], b.bounds[2]);
if (overlapEnd > overlapStart) {
this.visibleGaps.push({
axis: 'y',
gap,
startBounds: a.bounds,
endBounds: b.bounds,
overlap: [overlapStart, overlapEnd],
});
}
}
}
}
getReferencePoints(): readonly ReferencePoint[] {
return this.referencePoints;
}
getVisibleGaps(): readonly Gap[] {
return this.visibleGaps;
}
}
export function getSnapDistance(zoom: number): number {
return SNAP_THRESHOLD / zoom;
}
/**
* Collect snap candidates for a set of bounds (corners + edge midpoints + center).
*/
function getBoundsSnapCandidates(bounds: Bounds): { xs: number[]; ys: number[] } {
const [x1, y1, x2, y2] = bounds;
const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2;
return {
xs: [x1, cx, x2],
ys: [y1, cy, y2],
};
}
interface SnapMatch {
offset: number;
candidateValue: number;
refValue: number;
refPoint: ReferencePoint;
}
/**
* Find best point snaps on a single axis.
*/
function findAxisSnaps(
candidates: number[],
referencePoints: readonly ReferencePoint[],
axis: 'x' | 'y',
threshold: number,
): SnapMatch[] {
let bestOffset = Infinity;
let matches: SnapMatch[] = [];
for (const ref of referencePoints) {
const refVal = axis === 'x' ? ref.x : ref.y;
for (const cand of candidates) {
const offset = refVal - cand;
const absOffset = Math.abs(offset);
if (absOffset < threshold) {
if (absOffset < bestOffset) {
bestOffset = absOffset;
matches = [{ offset, candidateValue: cand, refValue: refVal, refPoint: ref }];
} else if (Math.abs(absOffset - bestOffset) < 0.01) {
matches.push({ offset, candidateValue: cand, refValue: refVal, refPoint: ref });
}
}
}
}
return matches;
}
/**
* Find gap snaps for a given bounds.
*/
function findGapSnaps(
bounds: Bounds,
gaps: readonly Gap[],
threshold: number,
): { xMatches: SnapMatch[]; yMatches: SnapMatch[] } {
const [sx1, sy1, sx2, sy2] = bounds;
let bestDx = Infinity;
let bestDy = Infinity;
let xMatches: SnapMatch[] = [];
let yMatches: SnapMatch[] = [];
for (const gap of gaps) {
if (gap.axis === 'x') {
// snap to right side: place element so that gap between endBounds right edge and element left = gap.gap
const rightOffset = (gap.endBounds[2] + gap.gap) - sx1;
if (Math.abs(rightOffset) < threshold && Math.abs(rightOffset) < bestDx) {
bestDx = Math.abs(rightOffset);
xMatches = [{ offset: rightOffset, candidateValue: sx1, refValue: gap.endBounds[2] + gap.gap, refPoint: { x: gap.endBounds[2] + gap.gap, y: (gap.overlap[0] + gap.overlap[1]) / 2, elementId: '' } }];
}
// snap to left side: place element so that gap between element right edge and startBounds left = gap.gap
const leftOffset = (gap.startBounds[0] - gap.gap) - sx2;
if (Math.abs(leftOffset) < threshold && Math.abs(leftOffset) < bestDx) {
bestDx = Math.abs(leftOffset);
xMatches = [{ offset: leftOffset, candidateValue: sx2, refValue: gap.startBounds[0] - gap.gap, refPoint: { x: gap.startBounds[0] - gap.gap, y: (gap.overlap[0] + gap.overlap[1]) / 2, elementId: '' } }];
}
} else {
const bottomOffset = (gap.endBounds[3] + gap.gap) - sy1;
if (Math.abs(bottomOffset) < threshold && Math.abs(bottomOffset) < bestDy) {
bestDy = Math.abs(bottomOffset);
yMatches = [{ offset: bottomOffset, candidateValue: sy1, refValue: gap.endBounds[3] + gap.gap, refPoint: { x: (gap.overlap[0] + gap.overlap[1]) / 2, y: gap.endBounds[3] + gap.gap, elementId: '' } }];
}
const topOffset = (gap.startBounds[1] - gap.gap) - sy2;
if (Math.abs(topOffset) < threshold && Math.abs(topOffset) < bestDy) {
bestDy = Math.abs(topOffset);
yMatches = [{ offset: topOffset, candidateValue: sy2, refValue: gap.startBounds[1] - gap.gap, refPoint: { x: (gap.overlap[0] + gap.overlap[1]) / 2, y: gap.startBounds[1] - gap.gap, elementId: '' } }];
}
}
}
return { xMatches, yMatches };
}
/**
* Create snap lines from matches, extending lines to cover both the reference
* element bounds and the dragged element bounds.
*/
function createSnapLinesFromMatches(
xMatches: SnapMatch[],
yMatches: SnapMatch[],
snappedBounds: Bounds,
allRefPoints: readonly ReferencePoint[],
): SnapLine[] {
const snapLines: SnapLine[] = [];
const [sx1, sy1, sx2, sy2] = snappedBounds;
if (xMatches.length > 0) {
const snapX = xMatches[0]!.refValue;
// Collect all Y values that align at this X (from reference points and dragged bounds)
const yValues: number[] = [sy1, sy2, (sy1 + sy2) / 2];
for (const ref of allRefPoints) {
if (Math.abs(ref.x - snapX) < 0.5) {
yValues.push(ref.y);
}
}
const minY = Math.min(...yValues) - 10;
const maxY = Math.max(...yValues) + 10;
snapLines.push({
type: 'points',
points: [
[snapX, minY] as GlobalPoint,
[snapX, maxY] as GlobalPoint,
],
});
}
if (yMatches.length > 0) {
const snapY = yMatches[0]!.refValue;
const xValues: number[] = [sx1, sx2, (sx1 + sx2) / 2];
for (const ref of allRefPoints) {
if (Math.abs(ref.y - snapY) < 0.5) {
xValues.push(ref.x);
}
}
const minX = Math.min(...xValues) - 10;
const maxX = Math.max(...xValues) + 10;
snapLines.push({
type: 'points',
points: [
[minX, snapY] as GlobalPoint,
[maxX, snapY] as GlobalPoint,
],
});
}
return snapLines;
}
/**
* Snap dragged elements to nearby reference points.
* Uses the ORIGINAL bounds (before drag) + dragOffset to compute snap.
*/
export function snapDraggedElements(
dragOffset: { x: number; y: number },
originalBounds: Bounds,
cache: SnapCache,
zoom: number,
): SnapResult {
const threshold = getSnapDistance(zoom);
const [ox1, oy1, ox2, oy2] = originalBounds;
// Projected bounds after applying drag offset
const projectedBounds: Bounds = [
ox1 + dragOffset.x,
oy1 + dragOffset.y,
ox2 + dragOffset.x,
oy2 + dragOffset.y,
];
const { xs: candidateXs, ys: candidateYs } = getBoundsSnapCandidates(projectedBounds);
// Point snaps
const xPointMatches = findAxisSnaps(candidateXs, cache.getReferencePoints(), 'x', threshold);
const yPointMatches = findAxisSnaps(candidateYs, cache.getReferencePoints(), 'y', threshold);
// Gap snaps
const { xMatches: xGapMatches, yMatches: yGapMatches } = findGapSnaps(
projectedBounds,
cache.getVisibleGaps(),
threshold,
);
// Pick best X snap (point vs gap)
let bestXOffset = 0;
let xMatches: SnapMatch[] = [];
const xPointBest = xPointMatches.length > 0 ? Math.abs(xPointMatches[0]!.offset) : Infinity;
const xGapBest = xGapMatches.length > 0 ? Math.abs(xGapMatches[0]!.offset) : Infinity;
if (xPointBest <= xGapBest && xPointBest < Infinity) {
bestXOffset = xPointMatches[0]!.offset;
xMatches = xPointMatches;
} else if (xGapBest < Infinity) {
bestXOffset = xGapMatches[0]!.offset;
xMatches = xGapMatches;
}
// Pick best Y snap
let bestYOffset = 0;
let yMatches: SnapMatch[] = [];
const yPointBest = yPointMatches.length > 0 ? Math.abs(yPointMatches[0]!.offset) : Infinity;
const yGapBest = yGapMatches.length > 0 ? Math.abs(yGapMatches[0]!.offset) : Infinity;
if (yPointBest <= yGapBest && yPointBest < Infinity) {
bestYOffset = yPointMatches[0]!.offset;
yMatches = yPointMatches;
} else if (yGapBest < Infinity) {
bestYOffset = yGapMatches[0]!.offset;
yMatches = yGapMatches;
}
const snappedOffset = {
x: dragOffset.x + bestXOffset,
y: dragOffset.y + bestYOffset,
};
// Compute snapped bounds for line generation
const snappedBounds: Bounds = [
ox1 + snappedOffset.x,
oy1 + snappedOffset.y,
ox2 + snappedOffset.x,
oy2 + snappedOffset.y,
];
const snapLines = createSnapLinesFromMatches(
xMatches,
yMatches,
snappedBounds,
cache.getReferencePoints(),
);
return { snappedOffset, snapLines };
}
/**
* Snap a new element being created.
*/
export function snapNewElement(
bounds: Bounds,
cache: SnapCache,
zoom: number,
): { snappedBounds: Bounds; snapLines: SnapLine[] } {
const threshold = getSnapDistance(zoom);
const { xs: candidateXs, ys: candidateYs } = getBoundsSnapCandidates(bounds);
const xMatches = findAxisSnaps(candidateXs, cache.getReferencePoints(), 'x', threshold);
const yMatches = findAxisSnaps(candidateYs, cache.getReferencePoints(), 'y', threshold);
const offsetX = xMatches.length > 0 ? xMatches[0]!.offset : 0;
const offsetY = yMatches.length > 0 ? yMatches[0]!.offset : 0;
const snappedBounds: Bounds = [
bounds[0] + offsetX,
bounds[1] + offsetY,
bounds[2] + offsetX,
bounds[3] + offsetY,
];
const snapLines = createSnapLinesFromMatches(
xMatches,
yMatches,
snappedBounds,
cache.getReferencePoints(),
);
return { snappedBounds, snapLines };
}
/**
* Snap during element resize.
*/
export function snapResizeElement(
resizingEdgeX: number | null,
resizingEdgeY: number | null,
cache: SnapCache,
zoom: number,
): { offsetX: number; offsetY: number; snapLines: SnapLine[] } {
const threshold = getSnapDistance(zoom);
let offsetX = 0;
let offsetY = 0;
const snapLines: SnapLine[] = [];
if (resizingEdgeX !== null) {
const xMatches = findAxisSnaps([resizingEdgeX], cache.getReferencePoints(), 'x', threshold);
if (xMatches.length > 0) {
offsetX = xMatches[0]!.offset;
const snapX = xMatches[0]!.refValue;
const yValues: number[] = [];
for (const ref of cache.getReferencePoints()) {
if (Math.abs(ref.x - snapX) < 0.5) {
yValues.push(ref.y);
}
}
if (resizingEdgeY !== null) yValues.push(resizingEdgeY);
if (yValues.length >= 2) {
const minY = Math.min(...yValues) - 10;
const maxY = Math.max(...yValues) + 10;
snapLines.push({
type: 'points',
points: [[snapX, minY] as GlobalPoint, [snapX, maxY] as GlobalPoint],
});
}
}
}
if (resizingEdgeY !== null) {
const yMatches = findAxisSnaps([resizingEdgeY], cache.getReferencePoints(), 'y', threshold);
if (yMatches.length > 0) {
offsetY = yMatches[0]!.offset;
const snapY = yMatches[0]!.refValue;
const xValues: number[] = [];
for (const ref of cache.getReferencePoints()) {
if (Math.abs(ref.y - snapY) < 0.5) {
xValues.push(ref.x);
}
}
if (resizingEdgeX !== null) xValues.push(resizingEdgeX);
if (xValues.length >= 2) {
const minX = Math.min(...xValues) - 10;
const maxX = Math.max(...xValues) + 10;
snapLines.push({
type: 'points',
points: [[minX, snapY] as GlobalPoint, [maxX, snapY] as GlobalPoint],
});
}
}
}
return { offsetX, offsetY, snapLines };
}
@@ -1,99 +0,0 @@
import type { DrawElement, AppState, BinaryFiles } from '../types';
import { getCommonBounds } from '../elements/bounds';
import { renderStaticScene } from '../core/renderer/static-scene';
const EXPORT_PADDING = 20;
export async function exportToCanvas(
elements: readonly DrawElement[],
appState: Partial<AppState>,
files: BinaryFiles,
imageCache: Map<string, HTMLImageElement>,
opts: { padding?: number; scale?: number; background?: boolean } = {},
): Promise<HTMLCanvasElement> {
const nonDeleted = elements.filter((el) => !el.isDeleted);
if (nonDeleted.length === 0) {
const canvas = document.createElement('canvas');
canvas.width = 1;
canvas.height = 1;
return canvas;
}
const [x1, y1, x2, y2] = getCommonBounds(nonDeleted);
const padding = opts.padding ?? EXPORT_PADDING;
const scale = opts.scale ?? 2;
const width = x2 - x1 + padding * 2;
const height = y2 - y1 + padding * 2;
const canvas = document.createElement('canvas');
const exportAppState: AppState = {
...getDefaultExportAppState(),
...appState,
width,
height,
scrollX: -x1 + padding,
scrollY: -y1 + padding,
zoom: { value: 1 as any },
gridModeEnabled: false,
} as AppState;
if (opts.background === false) {
exportAppState.viewBackgroundColor = 'transparent';
}
renderStaticScene(canvas, nonDeleted, exportAppState, files, imageCache);
if (scale !== 1) {
const scaledCanvas = document.createElement('canvas');
scaledCanvas.width = canvas.width * scale;
scaledCanvas.height = canvas.height * scale;
const ctx = scaledCanvas.getContext('2d');
if (ctx) {
ctx.scale(scale, scale);
ctx.drawImage(canvas, 0, 0);
}
return scaledCanvas;
}
return canvas;
}
export async function exportToBlob(
elements: readonly DrawElement[],
appState: Partial<AppState>,
files: BinaryFiles,
imageCache: Map<string, HTMLImageElement>,
opts: { padding?: number; scale?: number; background?: boolean; type?: string; quality?: number } = {},
): Promise<Blob> {
const canvas = await exportToCanvas(elements, appState, files, imageCache, opts);
return new Promise((resolve, reject) => {
canvas.toBlob(
(blob) => {
if (blob) resolve(blob);
else reject(new Error('Failed to export to blob'));
},
opts.type ?? 'image/png',
opts.quality ?? 0.92,
);
});
}
function getDefaultExportAppState(): AppState {
return {
viewBackgroundColor: '#ffffff',
zoom: { value: 1 as any },
scrollX: 0,
scrollY: 0,
width: 100,
height: 100,
theme: 'light',
gridModeEnabled: false,
gridSize: 20,
gridStep: 5,
exportBackground: true,
exportScale: 2,
exportWithDarkMode: false,
} as AppState;
}
@@ -1,439 +0,0 @@
import type {
DrawElement,
DrawTextElement,
DrawLinearElement,
DrawFreeDrawElement,
DrawImageElement,
DrawFrameElement,
Arrowhead,
AppState,
BinaryFiles,
} from '../types';
import { getCommonBounds } from '../elements/bounds';
import { FONT_FAMILY_FALLBACKS, SELECTION_BORDER_COLOR, getVerticalOffset } from '../constants';
import { getLineHeightInPx, BOUND_TEXT_PADDING } from '../elements/bound-text';
import { getFreeDrawOutlinePoints, getSvgPathFromStroke } from '../elements/shape-generator';
const SVG_NS = 'http://www.w3.org/2000/svg';
const EXPORT_PADDING = 20;
export function exportToSvg(
elements: readonly DrawElement[],
appState: Partial<AppState>,
files?: BinaryFiles,
opts: { padding?: number; background?: boolean } = {},
): SVGSVGElement {
const nonDeleted = elements.filter((el) => !el.isDeleted);
const padding = opts.padding ?? EXPORT_PADDING;
if (nonDeleted.length === 0) {
const svg = createSvgElement(100, 100);
return svg;
}
const [x1, y1, x2, y2] = getCommonBounds(nonDeleted);
const width = x2 - x1 + padding * 2;
const height = y2 - y1 + padding * 2;
const svg = createSvgElement(width, height);
if (opts.background !== false && appState.viewBackgroundColor) {
const bg = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
bg.setAttribute('width', String(width));
bg.setAttribute('height', String(height));
bg.setAttribute('fill', appState.viewBackgroundColor);
svg.appendChild(bg);
}
const boundTextByContainer = new Map<string, DrawTextElement>();
for (const el of nonDeleted) {
if (el.type === 'text') {
const textEl = el as DrawTextElement;
if (textEl.containerId) {
boundTextByContainer.set(textEl.containerId, textEl);
}
}
}
const defs = document.createElementNS(SVG_NS, 'defs');
svg.appendChild(defs);
const g = document.createElementNS(SVG_NS, 'g');
g.setAttribute('transform', `translate(${-x1 + padding}, ${-y1 + padding})`);
for (const element of nonDeleted) {
const boundText = boundTextByContainer.get(element.id);
const svgEl = elementToSvg(element, files, boundText, defs);
if (svgEl) {
g.appendChild(svgEl);
}
}
svg.appendChild(g);
return svg;
}
export function exportToSvgString(
elements: readonly DrawElement[],
appState: Partial<AppState>,
files?: BinaryFiles,
opts?: { padding?: number; background?: boolean },
): string {
const svg = exportToSvg(elements, appState, files, opts);
const serializer = new XMLSerializer();
return serializer.serializeToString(svg);
}
function createSvgElement(width: number, height: number): SVGSVGElement {
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
svg.setAttribute('width', String(Math.ceil(width)));
svg.setAttribute('height', String(Math.ceil(height)));
svg.setAttribute('viewBox', `0 0 ${Math.ceil(width)} ${Math.ceil(height)}`);
return svg;
}
function elementToSvg(
element: DrawElement,
files?: BinaryFiles,
boundText?: DrawTextElement,
defs?: SVGDefsElement,
): SVGElement | null {
const g = document.createElementNS(SVG_NS, 'g');
g.setAttribute(
'transform',
`translate(${element.x}, ${element.y})${
element.angle ? ` rotate(${(element.angle * 180) / Math.PI}, ${element.width / 2}, ${element.height / 2})` : ''
}`,
);
g.setAttribute('opacity', String(element.opacity / 100));
switch (element.type) {
case 'rectangle': {
const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
rect.setAttribute('width', String(element.width));
rect.setAttribute('height', String(element.height));
applyStrokeAndFill(rect, element);
if (element.roundness) {
const r = Math.min(element.width, element.height) * 0.1;
rect.setAttribute('rx', String(r));
rect.setAttribute('ry', String(r));
}
g.appendChild(rect);
break;
}
case 'ellipse': {
const ellipse = document.createElementNS('http://www.w3.org/2000/svg', 'ellipse');
ellipse.setAttribute('cx', String(element.width / 2));
ellipse.setAttribute('cy', String(element.height / 2));
ellipse.setAttribute('rx', String(element.width / 2));
ellipse.setAttribute('ry', String(element.height / 2));
applyStrokeAndFill(ellipse, element);
g.appendChild(ellipse);
break;
}
case 'diamond': {
const w = element.width;
const h = element.height;
const polygon = document.createElementNS('http://www.w3.org/2000/svg', 'polygon');
polygon.setAttribute(
'points',
`${w / 2},0 ${w},${h / 2} ${w / 2},${h} 0,${h / 2}`,
);
applyStrokeAndFill(polygon, element);
g.appendChild(polygon);
break;
}
case 'text': {
const textEl = element as DrawTextElement;
if (!textEl.text) break;
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
const fontFamily = FONT_FAMILY_FALLBACKS[textEl.fontFamily] ?? 'sans-serif';
text.setAttribute('font-size', String(textEl.fontSize));
text.setAttribute('font-family', fontFamily);
text.setAttribute('fill', textEl.strokeColor);
text.setAttribute('dominant-baseline', 'alphabetic');
text.setAttribute('text-anchor', textEl.textAlign === 'center' ? 'middle' : textEl.textAlign === 'right' ? 'end' : 'start');
text.setAttribute('style', 'white-space: pre;');
const lines = textEl.text.split('\n');
const lineHeightPx = getLineHeightInPx(textEl.fontSize, textEl.lineHeight);
const vOffset = getVerticalOffset(textEl.fontFamily, textEl.fontSize, lineHeightPx);
let textX = 0;
if (textEl.textAlign === 'center') textX = textEl.width / 2;
else if (textEl.textAlign === 'right') textX = textEl.width;
for (let i = 0; i < lines.length; i++) {
const line = lines[i]!;
const tspan = document.createElementNS('http://www.w3.org/2000/svg', 'tspan');
tspan.setAttribute('x', String(textX));
tspan.setAttribute('y', String(i * lineHeightPx + vOffset));
tspan.textContent = line || '\u00A0';
text.appendChild(tspan);
}
g.appendChild(text);
break;
}
case 'line':
case 'arrow': {
const linear = element as DrawLinearElement;
if (linear.points && linear.points.length >= 2) {
const lineEl = createLinearSvgElement(linear, element);
if (boundText && defs) {
const mask = document.createElementNS(SVG_NS, 'mask');
const maskId = `mask-${element.id}`;
mask.setAttribute('id', maskId);
const maskVisible = document.createElementNS(SVG_NS, 'rect');
maskVisible.setAttribute('x', '-10000');
maskVisible.setAttribute('y', '-10000');
maskVisible.setAttribute('width', '20000');
maskVisible.setAttribute('height', '20000');
maskVisible.setAttribute('fill', '#fff');
mask.appendChild(maskVisible);
const pad = BOUND_TEXT_PADDING;
const maskHole = document.createElementNS(SVG_NS, 'rect');
maskHole.setAttribute('x', String(boundText.x - element.x - pad));
maskHole.setAttribute('y', String(boundText.y - element.y - pad));
maskHole.setAttribute('width', String(boundText.width + pad * 2));
maskHole.setAttribute('height', String(boundText.height + pad * 2));
maskHole.setAttribute('fill', '#000');
mask.appendChild(maskHole);
defs.appendChild(mask);
const maskedGroup = document.createElementNS(SVG_NS, 'g');
maskedGroup.setAttribute('mask', `url(#${maskId})`);
maskedGroup.appendChild(lineEl);
g.appendChild(maskedGroup);
} else {
g.appendChild(lineEl);
}
{
const pts = linear.points;
if (linear.endArrowhead && pts.length >= 2) {
const tip = pts[pts.length - 1]!;
const prev = pts[pts.length - 2]!;
const arrowEl = createArrowheadSvg(prev, tip, linear.endArrowhead, element.strokeColor, element.strokeWidth);
if (arrowEl) g.appendChild(arrowEl);
}
if (linear.startArrowhead && pts.length >= 2) {
const tip = pts[0]!;
const prev = pts[1]!;
const arrowEl = createArrowheadSvg(prev, tip, linear.startArrowhead, element.strokeColor, element.strokeWidth);
if (arrowEl) g.appendChild(arrowEl);
}
}
}
break;
}
case 'freedraw': {
const fd = element as DrawFreeDrawElement;
if (fd.points && fd.points.length >= 2) {
const outlinePoints = getFreeDrawOutlinePoints(fd);
const d = getSvgPathFromStroke(outlinePoints);
if (d) {
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('d', d);
path.setAttribute('fill', element.strokeColor);
path.setAttribute('stroke', 'none');
g.appendChild(path);
}
}
break;
}
case 'image': {
const imgEl = element as DrawImageElement;
if (imgEl.fileId && files?.[imgEl.fileId]) {
const fileData = files[imgEl.fileId]!;
const image = document.createElementNS('http://www.w3.org/2000/svg', 'image');
image.setAttribute('width', String(element.width));
image.setAttribute('height', String(element.height));
image.setAttributeNS('http://www.w3.org/1999/xlink', 'href', fileData.dataURL);
g.appendChild(image);
}
break;
}
case 'frame': {
const frameEl = element as DrawFrameElement;
const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
rect.setAttribute('width', String(element.width));
rect.setAttribute('height', String(element.height));
rect.setAttribute('fill', 'none');
rect.setAttribute('stroke', SELECTION_BORDER_COLOR);
rect.setAttribute('stroke-width', '2');
rect.setAttribute('stroke-dasharray', '8 4');
g.appendChild(rect);
if (frameEl.name) {
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
text.setAttribute('x', '0');
text.setAttribute('y', '-4');
text.setAttribute('font-size', '12');
text.setAttribute('font-family', 'sans-serif');
text.setAttribute('fill', SELECTION_BORDER_COLOR);
text.setAttribute('text-anchor', 'start');
text.textContent = frameEl.name;
g.appendChild(text);
}
break;
}
default:
return null;
}
return g;
}
function createLinearSvgElement(
linear: DrawLinearElement,
element: DrawElement,
): SVGElement {
const pts = linear.points as [number, number][];
if (element.roundness && pts.length > 2) {
const path = document.createElementNS(SVG_NS, 'path');
const d = catmullRomToSvgPath(pts);
path.setAttribute('d', d);
path.setAttribute('fill', 'none');
path.setAttribute('stroke', element.strokeColor);
path.setAttribute('stroke-width', String(element.strokeWidth));
path.setAttribute('stroke-linecap', 'round');
path.setAttribute('stroke-linejoin', 'round');
applyStrokeDash(path, element);
return path;
}
const polyline = document.createElementNS(SVG_NS, 'polyline');
const pointsStr = pts.map((p) => `${p[0]},${p[1]}`).join(' ');
polyline.setAttribute('points', pointsStr);
polyline.setAttribute('fill', 'none');
polyline.setAttribute('stroke', element.strokeColor);
polyline.setAttribute('stroke-width', String(element.strokeWidth));
applyStrokeDash(polyline, element);
return polyline;
}
function catmullRomToSvgPath(points: [number, number][]): string {
if (points.length < 2) return '';
if (points.length === 2) {
return `M ${points[0]![0]},${points[0]![1]} L ${points[1]![0]},${points[1]![1]}`;
}
const parts: string[] = [`M ${points[0]![0]},${points[0]![1]}`];
for (let i = 0; i < points.length - 1; i++) {
const p0 = points[Math.max(0, i - 1)]!;
const p1 = points[i]!;
const p2 = points[i + 1]!;
const p3 = points[Math.min(points.length - 1, i + 2)]!;
const cp1x = p1[0] + (p2[0] - p0[0]) / 6;
const cp1y = p1[1] + (p2[1] - p0[1]) / 6;
const cp2x = p2[0] - (p3[0] - p1[0]) / 6;
const cp2y = p2[1] - (p3[1] - p1[1]) / 6;
parts.push(`C ${cp1x},${cp1y} ${cp2x},${cp2y} ${p2[0]},${p2[1]}`);
}
return parts.join(' ');
}
function createArrowheadSvg(
from: [number, number],
to: [number, number],
type: Arrowhead,
color: string,
strokeWidth: number,
): SVGElement | null {
const angle = Math.atan2(to[1] - from[1], to[0] - from[0]);
const size = Math.max(10, strokeWidth * 4);
const leftAngle = angle + Math.PI + Math.PI / 6;
const rightAngle = angle + Math.PI - Math.PI / 6;
const lx = to[0] + size * Math.cos(leftAngle);
const ly = to[1] + size * Math.sin(leftAngle);
const rx = to[0] + size * Math.cos(rightAngle);
const ry = to[1] + size * Math.sin(rightAngle);
switch (type) {
case 'arrow': {
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('d', `M ${lx},${ly} L ${to[0]},${to[1]} L ${rx},${ry}`);
path.setAttribute('fill', 'none');
path.setAttribute('stroke', color);
path.setAttribute('stroke-width', String(strokeWidth));
path.setAttribute('stroke-linejoin', 'round');
path.setAttribute('stroke-linecap', 'round');
return path;
}
case 'triangle': {
const polygon = document.createElementNS('http://www.w3.org/2000/svg', 'polygon');
polygon.setAttribute('points', `${to[0]},${to[1]} ${lx},${ly} ${rx},${ry}`);
polygon.setAttribute('fill', color);
polygon.setAttribute('stroke', 'none');
return polygon;
}
case 'circle': {
const r = size / 2;
const cx = to[0] + (r * Math.cos(angle + Math.PI));
const cy = to[1] + (r * Math.sin(angle + Math.PI));
const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
circle.setAttribute('cx', String(cx));
circle.setAttribute('cy', String(cy));
circle.setAttribute('r', String(r));
circle.setAttribute('fill', color);
circle.setAttribute('stroke', 'none');
return circle;
}
case 'diamond': {
const half = size / 2;
const backX = to[0] + size * Math.cos(angle + Math.PI);
const backY = to[1] + size * Math.sin(angle + Math.PI);
const midX = (to[0] + backX) / 2;
const midY = (to[1] + backY) / 2;
const perpX = half * Math.cos(angle + Math.PI / 2);
const perpY = half * Math.sin(angle + Math.PI / 2);
const polygon = document.createElementNS('http://www.w3.org/2000/svg', 'polygon');
polygon.setAttribute('points', `${to[0]},${to[1]} ${midX + perpX},${midY + perpY} ${backX},${backY} ${midX - perpX},${midY - perpY}`);
polygon.setAttribute('fill', color);
polygon.setAttribute('stroke', 'none');
return polygon;
}
case 'bar': {
const perpX = (size / 2) * Math.cos(angle + Math.PI / 2);
const perpY = (size / 2) * Math.sin(angle + Math.PI / 2);
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
line.setAttribute('x1', String(to[0] + perpX));
line.setAttribute('y1', String(to[1] + perpY));
line.setAttribute('x2', String(to[0] - perpX));
line.setAttribute('y2', String(to[1] - perpY));
line.setAttribute('stroke', color);
line.setAttribute('stroke-width', String(strokeWidth));
line.setAttribute('stroke-linecap', 'round');
return line;
}
default:
return null;
}
}
function applyStrokeDash(svgEl: SVGElement, element: DrawElement): void {
if (element.strokeStyle === 'dashed') {
svgEl.setAttribute('stroke-dasharray', '12 8');
} else if (element.strokeStyle === 'dotted') {
svgEl.setAttribute('stroke-dasharray', '3 6');
}
}
function applyStrokeAndFill(svgEl: SVGElement, element: DrawElement): void {
svgEl.setAttribute('stroke', element.strokeColor);
svgEl.setAttribute('stroke-width', String(element.strokeWidth));
svgEl.setAttribute(
'fill',
element.backgroundColor !== 'transparent' ? element.backgroundColor : 'none',
);
applyStrokeDash(svgEl, element);
}
@@ -1,58 +0,0 @@
import type { DrawElement, AppState, BinaryFiles, DrawData } from '../types';
import { DRAW_VERSION, DRAW_SOURCE } from '../constants';
export function serializeAsJSON(
elements: readonly DrawElement[],
appState: Partial<AppState>,
files: BinaryFiles,
): string {
const data: DrawData = {
type: 'zq-draw',
version: DRAW_VERSION,
source: DRAW_SOURCE,
elements: elements.filter((el) => !el.isDeleted),
appState: {
viewBackgroundColor: appState.viewBackgroundColor,
gridModeEnabled: appState.gridModeEnabled,
gridSize: appState.gridSize,
theme: appState.theme,
name: appState.name,
},
files,
};
return JSON.stringify(data, null, 2);
}
export function deserializeFromJSON(json: string): DrawData | null {
try {
const data = JSON.parse(json);
if (data.type !== 'zq-draw') {
console.warn('Unknown draw data type:', data.type);
}
return data as DrawData;
} catch {
console.error('Failed to parse draw JSON');
return null;
}
}
export function toDrawData(
elements: readonly DrawElement[],
appState: Partial<AppState>,
files: BinaryFiles,
): DrawData {
return {
type: 'zq-draw',
version: DRAW_VERSION,
source: DRAW_SOURCE,
elements: elements.filter((el) => !el.isDeleted),
appState: {
viewBackgroundColor: appState.viewBackgroundColor,
gridModeEnabled: appState.gridModeEnabled,
gridSize: appState.gridSize,
theme: appState.theme,
name: appState.name,
},
files,
};
}
@@ -1,127 +0,0 @@
import type { Scene } from '../core/scene';
import type { DrawElement } from '../types';
import type { Bounds } from '../math/types';
import { getElementBounds, getCommonBounds } from './bounds';
import { getMaximumGroups } from './group';
export interface Alignment {
position: 'start' | 'center' | 'end';
axis: 'x' | 'y';
}
export interface Distribution {
space: 'between';
axis: 'x' | 'y';
}
function getGroupBounds(group: readonly DrawElement[]): Bounds {
return getCommonBounds(group);
}
export function alignElements(
selectedElements: readonly DrawElement[],
alignment: Alignment,
scene: Scene,
): void {
if (selectedElements.length < 2) return;
const groups = getMaximumGroups(selectedElements);
if (groups.length < 2) return;
const allBounds = getCommonBounds(selectedElements);
const { axis, position } = alignment;
for (const group of groups) {
const groupBounds = getGroupBounds(group);
const translation = calculateTranslation(groupBounds, allBounds, axis, position);
if (translation === 0) continue;
for (const el of group) {
if (axis === 'x') {
scene.mutateElement(el.id, { x: el.x + translation } as Partial<DrawElement>);
} else {
scene.mutateElement(el.id, { y: el.y + translation } as Partial<DrawElement>);
}
}
}
}
function calculateTranslation(
groupBounds: Bounds,
allBounds: Bounds,
axis: 'x' | 'y',
position: 'start' | 'center' | 'end',
): number {
const idx = axis === 'x' ? 0 : 1;
const endIdx = axis === 'x' ? 2 : 3;
const groupStart = groupBounds[idx];
const groupEnd = groupBounds[endIdx];
const groupCenter = (groupStart + groupEnd) / 2;
const allStart = allBounds[idx];
const allEnd = allBounds[endIdx];
const allCenter = (allStart + allEnd) / 2;
switch (position) {
case 'start':
return allStart - groupStart;
case 'center':
return allCenter - groupCenter;
case 'end':
return allEnd - groupEnd;
}
}
export function distributeElements(
selectedElements: readonly DrawElement[],
distribution: Distribution,
scene: Scene,
): void {
if (selectedElements.length < 3) return;
const groups = getMaximumGroups(selectedElements);
if (groups.length < 3) return;
const { axis } = distribution;
const idx = axis === 'x' ? 0 : 1;
const endIdx = axis === 'x' ? 2 : 3;
const groupsWithBounds = groups.map((group) => ({
group,
bounds: getGroupBounds(group),
}));
groupsWithBounds.sort((a, b) => {
const aCenter = (a.bounds[idx] + a.bounds[endIdx]) / 2;
const bCenter = (b.bounds[idx] + b.bounds[endIdx]) / 2;
return aCenter - bCenter;
});
const allBounds = getCommonBounds(selectedElements);
const totalSpan = allBounds[endIdx] - allBounds[idx];
const totalGroupSize = groupsWithBounds.reduce(
(sum, { bounds }) => sum + (bounds[endIdx] - bounds[idx]),
0,
);
const gap = (totalSpan - totalGroupSize) / (groupsWithBounds.length - 1);
let currentPos = allBounds[idx];
for (const { group, bounds } of groupsWithBounds) {
const groupSize = bounds[endIdx] - bounds[idx];
const offset = currentPos - bounds[idx];
if (Math.abs(offset) > 0.5) {
for (const el of group) {
if (axis === 'x') {
scene.mutateElement(el.id, { x: el.x + offset } as Partial<DrawElement>);
} else {
scene.mutateElement(el.id, { y: el.y + offset } as Partial<DrawElement>);
}
}
}
currentPos += groupSize + gap;
}
}
@@ -1,425 +0,0 @@
import type { Scene } from '../core/scene';
import type {
DrawElement,
DrawArrowElement,
DrawLinearElement,
BoundElement,
FixedPointBinding,
GlobalPoint,
NonDeletedDrawElement,
} from '../types';
import { getElementBounds } from './bounds';
import { pointDistance, pointRotate } from '../math/point';
import type { Point } from '../math/types';
import { handleBoundTextResize } from './bound-text';
const BINDING_THRESHOLD = 15;
const SNAP_DISTANCE = 15;
const BINDABLE_TYPES = new Set([
'rectangle',
'ellipse',
'diamond',
'frame',
'image',
'embeddable',
]);
export function isBindableElement(element: DrawElement): boolean {
return BINDABLE_TYPES.has(element.type);
}
export function isLinearElement(element: DrawElement): element is DrawLinearElement {
return element.type === 'line' || element.type === 'arrow';
}
export function isArrowElement(element: DrawElement): element is DrawArrowElement {
return element.type === 'arrow';
}
/**
* Find the closest bindable element near a point, preferring smaller shapes.
*/
export function getHoveredElementForBinding(
point: GlobalPoint,
elements: readonly NonDeletedDrawElement[],
zoom: number = 1,
): NonDeletedDrawElement | null {
const threshold = BINDING_THRESHOLD / zoom;
const candidates: NonDeletedDrawElement[] = [];
for (let i = elements.length - 1; i >= 0; i--) {
const el = elements[i]!;
if (!isBindableElement(el) || el.locked) continue;
if (isPointNearElement(point, el, threshold)) {
candidates.push(el);
}
}
if (candidates.length === 0) return null;
if (candidates.length === 1) return candidates[0]!;
return candidates.sort(
(a, b) => b.width ** 2 + b.height ** 2 - (a.width ** 2 + a.height ** 2),
).pop()!;
}
function isPointNearElement(
point: GlobalPoint,
element: DrawElement,
threshold: number,
): boolean {
const [x1, y1, x2, y2] = getElementBounds(element);
return (
point[0] >= x1 - threshold &&
point[0] <= x2 + threshold &&
point[1] >= y1 - threshold &&
point[1] <= y2 + threshold
);
}
/**
* Calculate the fixed point ratio (0-1 range) for a point on an element.
*/
export function calculateFixedPoint(
point: GlobalPoint,
element: DrawElement,
): [number, number] {
const { x, y, width, height, angle } = element;
const cx = x + width / 2;
const cy = y + height / 2;
const localPoint = angle !== 0
? pointRotate([point[0], point[1]] as Point, -angle, [cx, cy])
: [point[0], point[1]];
const ratioX = width === 0 ? 0.5 : Math.max(0, Math.min(1, (localPoint[0] - x) / width));
const ratioY = height === 0 ? 0.5 : Math.max(0, Math.min(1, (localPoint[1] - y) / height));
return [ratioX, ratioY];
}
/**
* Convert a fixed point ratio back to a global coordinate.
*/
export function getGlobalFixedPoint(
fixedPoint: [number, number],
element: DrawElement,
): GlobalPoint {
const { x, y, width, height, angle } = element;
const localX = x + fixedPoint[0] * width;
const localY = y + fixedPoint[1] * height;
if (angle === 0) {
return [localX, localY] as GlobalPoint;
}
const cx = x + width / 2;
const cy = y + height / 2;
const rotated = pointRotate([localX, localY] as Point, angle, [cx, cy]);
return rotated as unknown as GlobalPoint;
}
/**
* Find the intersection point of a line from element center to a target point
* with the element's outline.
*/
export function intersectElementOutline(
element: DrawElement,
targetPoint: GlobalPoint,
): GlobalPoint {
const { x, y, width, height, angle } = element;
const cx = x + width / 2;
const cy = y + height / 2;
const localTarget = angle !== 0
? pointRotate([targetPoint[0], targetPoint[1]] as Point, -angle, [cx, cy])
: [targetPoint[0], targetPoint[1]];
let intersectX: number;
let intersectY: number;
if (element.type === 'ellipse') {
const rx = width / 2;
const ry = height / 2;
const dx = localTarget[0] - cx;
const dy = localTarget[1] - cy;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist === 0) {
intersectX = cx + rx;
intersectY = cy;
} else {
const ndx = dx / dist;
const ndy = dy / dist;
const t = 1 / Math.sqrt((ndx / rx) ** 2 + (ndy / ry) ** 2);
intersectX = cx + ndx * t;
intersectY = cy + ndy * t;
}
} else if (element.type === 'diamond') {
const dx = localTarget[0] - cx;
const dy = localTarget[1] - cy;
const hw = width / 2;
const hh = height / 2;
const absDx = Math.abs(dx);
const absDy = Math.abs(dy);
if (absDx === 0 && absDy === 0) {
intersectX = cx;
intersectY = cy - hh;
} else {
const t = 1 / (absDx / hw + absDy / hh);
intersectX = cx + dx * t;
intersectY = cy + dy * t;
}
} else {
const dx = localTarget[0] - cx;
const dy = localTarget[1] - cy;
const hw = width / 2;
const hh = height / 2;
if (dx === 0 && dy === 0) {
intersectX = cx + hw;
intersectY = cy;
} else {
const scaleX = hw / Math.abs(dx || 1);
const scaleY = hh / Math.abs(dy || 1);
const scale = Math.min(scaleX, scaleY);
intersectX = cx + dx * scale;
intersectY = cy + dy * scale;
}
}
if (angle !== 0) {
const rotated = pointRotate(
[intersectX, intersectY] as Point,
angle,
[cx, cy],
);
return rotated as unknown as GlobalPoint;
}
return [intersectX, intersectY] as GlobalPoint;
}
/**
* Bind an arrow endpoint to a target element.
*/
export function bindArrowToElement(
arrow: DrawArrowElement,
endpoint: 'start' | 'end',
target: DrawElement,
scene: Scene,
): void {
const pointIndex = endpoint === 'start' ? 0 : arrow.points.length - 1;
const point = arrow.points[pointIndex]!;
const globalPoint: GlobalPoint = [
arrow.x + point[0],
arrow.y + point[1],
] as GlobalPoint;
const fixedPoint = calculateFixedPoint(globalPoint, target);
const binding: FixedPointBinding = {
elementId: target.id,
fixedPoint,
};
const bindingKey = endpoint === 'start' ? 'startBinding' : 'endBinding';
scene.mutateElement(arrow.id, {
[bindingKey]: binding,
} as Partial<DrawElement>);
const existingBound = target.boundElements || [];
if (!existingBound.some((b) => b.id === arrow.id)) {
const newBound: BoundElement[] = [
...existingBound,
{ id: arrow.id, type: 'arrow' },
];
scene.mutateElement(target.id, {
boundElements: newBound,
} as Partial<DrawElement>);
}
}
/**
* Unbind an arrow endpoint from its bound element.
*/
export function unbindArrowFromElement(
arrow: DrawArrowElement,
endpoint: 'start' | 'end',
scene: Scene,
): void {
const bindingKey = endpoint === 'start' ? 'startBinding' : 'endBinding';
const binding = arrow[bindingKey];
if (!binding) return;
scene.mutateElement(arrow.id, {
[bindingKey]: null,
} as Partial<DrawElement>);
const target = scene.getElement(binding.elementId);
if (target && target.boundElements) {
const newBound = target.boundElements.filter((b) => b.id !== arrow.id);
scene.mutateElement(target.id, {
boundElements: newBound.length > 0 ? newBound : null,
} as Partial<DrawElement>);
}
}
/**
* After a bindable element is moved/resized, update all arrows bound to it.
*/
export function updateBoundElements(
element: DrawElement,
scene: Scene,
): void {
if (!element.boundElements) return;
for (const bound of element.boundElements) {
if (bound.type !== 'arrow') continue;
const arrow = scene.getElement(bound.id) as DrawArrowElement | undefined;
if (!arrow || arrow.isDeleted) continue;
updateArrowBinding(arrow, element, scene);
const updatedArrow = scene.getElement(bound.id);
if (updatedArrow) {
handleBoundTextResize(updatedArrow, scene);
}
}
}
function updateArrowBinding(
arrow: DrawArrowElement,
boundElement: DrawElement,
scene: Scene,
): void {
const updates: Partial<DrawLinearElement> = {};
let changed = false;
if (arrow.startBinding?.elementId === boundElement.id) {
const outlinePoint = intersectElementOutline(boundElement, getArrowOtherEnd(arrow, 'start'));
const newLocal = [outlinePoint[0] - arrow.x, outlinePoint[1] - arrow.y] as unknown;
const newPoints = [...arrow.points];
newPoints[0] = newLocal as any;
updates.points = newPoints;
changed = true;
}
if (arrow.endBinding?.elementId === boundElement.id) {
const lastIdx = arrow.points.length - 1;
const outlinePoint = intersectElementOutline(boundElement, getArrowOtherEnd(arrow, 'end'));
const newLocal = [outlinePoint[0] - arrow.x, outlinePoint[1] - arrow.y] as unknown;
const newPoints = updates.points ? [...updates.points] : [...arrow.points];
newPoints[lastIdx] = newLocal as any;
updates.points = newPoints;
changed = true;
}
if (changed) {
scene.mutateElement(arrow.id, updates as Partial<DrawElement>);
}
}
function getArrowOtherEnd(
arrow: DrawArrowElement,
currentEndpoint: 'start' | 'end',
): GlobalPoint {
if (currentEndpoint === 'start') {
const lastPoint = arrow.points[arrow.points.length - 1]!;
return [arrow.x + lastPoint[0], arrow.y + lastPoint[1]] as GlobalPoint;
}
const firstPoint = arrow.points[0]!;
return [arrow.x + firstPoint[0], arrow.y + firstPoint[1]] as GlobalPoint;
}
/**
* Get the four edge midpoints of a bindable element, accounting for rotation.
*/
export function getElementSnapPoints(element: DrawElement): GlobalPoint[] {
const { x, y, width, height, angle } = element;
const cx = x + width / 2;
const cy = y + height / 2;
let midpoints: Point[];
if (element.type === 'diamond') {
midpoints = [
[cx, y], // top vertex
[x + width, cy], // right vertex
[cx, y + height], // bottom vertex
[x, cy], // left vertex
];
} else if (element.type === 'ellipse') {
midpoints = [
[cx, y], // top
[x + width, cy], // right
[cx, y + height], // bottom
[x, cy], // left
];
} else {
midpoints = [
[cx, y], // top
[x + width, cy], // right
[cx, y + height], // bottom
[x, cy], // left
];
}
if (angle === 0) {
return midpoints as unknown as GlobalPoint[];
}
return midpoints.map(
(p) => pointRotate(p, angle, [cx, cy]) as unknown as GlobalPoint,
);
}
/**
* Find the closest snap midpoint on an element near a given point.
*/
export function getSnapMidpointNear(
point: GlobalPoint,
element: DrawElement,
zoom: number,
): GlobalPoint | null {
const threshold = SNAP_DISTANCE / zoom;
const midpoints = getElementSnapPoints(element);
let closest: GlobalPoint | null = null;
let minDist = Infinity;
for (const mp of midpoints) {
const d = pointDistance(
[point[0], point[1]] as Point,
[mp[0], mp[1]] as Point,
);
if (d <= threshold && d < minDist) {
minDist = d;
closest = mp;
}
}
return closest;
}
/**
* Unbind all arrows from an element (used before deletion).
*/
export function unbindAllBoundElements(
element: DrawElement,
scene: Scene,
): void {
if (!element.boundElements) return;
for (const bound of element.boundElements) {
const boundEl = scene.getElement(bound.id);
if (!boundEl || boundEl.isDeleted) continue;
if (bound.type === 'arrow' && isArrowElement(boundEl)) {
if (boundEl.startBinding?.elementId === element.id) {
scene.mutateElement(boundEl.id, { startBinding: null } as Partial<DrawElement>);
}
if (boundEl.endBinding?.elementId === element.id) {
scene.mutateElement(boundEl.id, { endBinding: null } as Partial<DrawElement>);
}
}
}
}
@@ -1,506 +0,0 @@
import type { Scene } from '../core/scene';
import type {
DrawElement,
DrawTextElement,
DrawLinearElement,
BoundElement,
TextAlign,
VerticalAlign,
} from '../types';
import { FONT_FAMILY_FALLBACKS, DEFAULT_LINE_HEIGHT, DEFAULT_FONT_SIZE } from '../constants';
export const BOUND_TEXT_PADDING = 5;
const ARROW_LABEL_WIDTH_FRACTION = 0.7;
const ARROW_LABEL_FONT_SIZE_TO_MIN_WIDTH_RATIO = 11;
const VALID_CONTAINER_TYPES = new Set([
'rectangle',
'ellipse',
'diamond',
'arrow',
]);
let _measureCanvas: HTMLCanvasElement | null = null;
function getMeasureContext(): CanvasRenderingContext2D {
if (!_measureCanvas) {
_measureCanvas = document.createElement('canvas');
}
return _measureCanvas.getContext('2d')!;
}
export function getFontString(fontSize: number, fontFamily: number): string {
return `${fontSize}px ${FONT_FAMILY_FALLBACKS[fontFamily] || 'sans-serif'}`;
}
export function getLineHeightInPx(fontSize: number, lineHeight: number): number {
return fontSize * lineHeight;
}
export function isTextBindableContainer(element: DrawElement): boolean {
return VALID_CONTAINER_TYPES.has(element.type);
}
export function isBoundToContainer(element: DrawElement): boolean {
return element.type === 'text' && !!(element as DrawTextElement).containerId;
}
export function getBoundTextElement(
container: DrawElement,
scene: Scene,
): DrawTextElement | null {
if (!container.boundElements) return null;
const textBound = container.boundElements.find((b) => b.type === 'text');
if (!textBound) return null;
const el = scene.getElement(textBound.id);
if (!el || el.isDeleted || el.type !== 'text') return null;
return el as DrawTextElement;
}
export function getContainerElement(
textElement: DrawTextElement,
scene: Scene,
): DrawElement | null {
if (!textElement.containerId) return null;
const el = scene.getElement(textElement.containerId);
if (!el || el.isDeleted) return null;
return el;
}
export function getBoundTextMaxWidth(container: DrawElement): number {
const padding = BOUND_TEXT_PADDING * 2;
if (container.type === 'ellipse') {
return Math.floor(((container.width / 2) * Math.sqrt(2)) * 2 - padding);
}
if (container.type === 'diamond') {
return Math.floor(container.width / 2 - padding);
}
if (container.type === 'arrow' || container.type === 'line') {
const linearWidth = Math.max(container.width, container.height, 1);
return Math.max(
Math.floor(linearWidth * ARROW_LABEL_WIDTH_FRACTION),
DEFAULT_FONT_SIZE * ARROW_LABEL_FONT_SIZE_TO_MIN_WIDTH_RATIO,
);
}
return Math.floor(container.width - padding);
}
export function getBoundTextMaxHeight(
container: DrawElement,
textElement?: DrawTextElement,
): number {
const padding = BOUND_TEXT_PADDING * 2;
if (container.type === 'ellipse') {
return Math.floor(((container.height / 2) * Math.sqrt(2)) * 2 - padding);
}
if (container.type === 'diamond') {
return Math.floor(container.height / 2 - padding);
}
if (container.type === 'arrow' || container.type === 'line') {
return Infinity;
}
return Math.floor(container.height - padding);
}
export function computeContainerDimensionForBoundText(
dimension: number,
containerType: string,
): number {
dimension = Math.ceil(dimension);
const padding = BOUND_TEXT_PADDING * 2;
if (containerType === 'ellipse') {
return Math.round(((dimension + padding) / (Math.sqrt(2))) * 2);
}
if (containerType === 'arrow' || containerType === 'line') {
return dimension + padding * 8;
}
if (containerType === 'diamond') {
return 2 * (dimension + padding);
}
return dimension + padding;
}
export function getContainerTextCoords(container: DrawElement): { x: number; y: number } {
if (container.type === 'ellipse') {
const hw = container.width / 2;
const hh = container.height / 2;
const inscribedHalfW = hw * Math.SQRT1_2;
const inscribedHalfH = hh * Math.SQRT1_2;
return {
x: container.x + hw - inscribedHalfW + BOUND_TEXT_PADDING,
y: container.y + hh - inscribedHalfH + BOUND_TEXT_PADDING,
};
}
if (container.type === 'diamond') {
const hw = container.width / 4;
const hh = container.height / 4;
return {
x: container.x + container.width / 2 - hw + BOUND_TEXT_PADDING,
y: container.y + container.height / 2 - hh + BOUND_TEXT_PADDING,
};
}
return {
x: container.x + BOUND_TEXT_PADDING,
y: container.y + BOUND_TEXT_PADDING,
};
}
export function getArrowLabelPosition(
arrow: DrawLinearElement,
): { x: number; y: number } {
const points = arrow.points;
if (!points || points.length < 2) {
return { x: arrow.x, y: arrow.y };
}
if (points.length % 2 === 1) {
const midIdx = Math.floor(points.length / 2);
const pt = points[midIdx]!;
return {
x: arrow.x + pt[0],
y: arrow.y + pt[1],
};
}
const midIdx = Math.floor(points.length / 2) - 1;
const p1 = points[midIdx]!;
const p2 = points[midIdx + 1]!;
return {
x: arrow.x + (p1[0] + p2[0]) / 2,
y: arrow.y + (p1[1] + p2[1]) / 2,
};
}
export function computeBoundTextPosition(
container: DrawElement,
textElement: DrawTextElement,
scene?: Scene,
): { x: number; y: number } {
if (container.type === 'arrow' || container.type === 'line') {
const arrow = container as DrawLinearElement;
const center = getArrowLabelPosition(arrow);
return {
x: center.x - textElement.width / 2,
y: center.y - textElement.height / 2,
};
}
const maxWidth = getBoundTextMaxWidth(container);
const maxHeight = getBoundTextMaxHeight(container, textElement);
const textWidth = textElement.width;
const textHeight = textElement.height;
let offsetX: number;
switch (textElement.textAlign as TextAlign) {
case 'center':
offsetX = (maxWidth - textWidth) / 2;
break;
case 'right':
offsetX = maxWidth - textWidth;
break;
default:
offsetX = 0;
}
let offsetY: number;
switch (textElement.verticalAlign as VerticalAlign) {
case 'middle':
offsetY = (maxHeight - textHeight) / 2;
break;
case 'bottom':
offsetY = maxHeight - textHeight;
break;
default:
offsetY = 0;
}
if (container.type === 'ellipse' || container.type === 'diamond') {
const cx = container.x + container.width / 2;
const cy = container.y + container.height / 2;
const areaX = cx - maxWidth / 2;
const areaY = cy - getBoundTextMaxHeight(container, textElement) / 2;
return {
x: areaX + offsetX,
y: areaY + offsetY,
};
}
const coords = getContainerTextCoords(container);
return {
x: coords.x + offsetX,
y: coords.y + offsetY,
};
}
export function measureText(
text: string,
fontSize: number,
fontFamily: number,
lineHeight: number,
maxWidth?: number,
): { width: number; height: number; wrappedText: string } {
const ctx = getMeasureContext();
const fontString = getFontString(fontSize, fontFamily);
ctx.font = fontString;
const lineHeightPx = getLineHeightInPx(fontSize, lineHeight);
const inputText = text || ' ';
let lines: string[];
let wrappedText: string;
if (maxWidth && maxWidth > 0 && isFinite(maxWidth)) {
lines = wrapText(inputText, ctx, maxWidth);
wrappedText = lines.join('\n');
} else {
lines = inputText.split('\n');
wrappedText = inputText;
}
let width = 0;
for (const line of lines) {
const metrics = ctx.measureText(line || ' ');
width = Math.max(width, metrics.width);
}
const height = lines.length * lineHeightPx;
return {
width: Math.ceil(width),
height: Math.ceil(height),
wrappedText,
};
}
export function getTextWidth(
text: string,
fontSize: number,
fontFamily: number,
): number {
const ctx = getMeasureContext();
ctx.font = getFontString(fontSize, fontFamily);
const lines = text.split('\n');
let maxW = 0;
for (const line of lines) {
maxW = Math.max(maxW, ctx.measureText(line || ' ').width);
}
return Math.ceil(maxW);
}
export function wrapText(
text: string,
ctx: CanvasRenderingContext2D,
maxWidth: number,
): string[] {
const paragraphs = text.split('\n');
const lines: string[] = [];
for (const paragraph of paragraphs) {
if (paragraph === '') {
lines.push('');
continue;
}
const chars = [...paragraph];
let currentLine = '';
for (let i = 0; i < chars.length; i++) {
const char = chars[i]!;
const testLine = currentLine + char;
const metrics = ctx.measureText(testLine);
if (metrics.width > maxWidth && currentLine.length > 0) {
const lastSpaceIdx = currentLine.lastIndexOf(' ');
if (lastSpaceIdx > 0) {
lines.push(currentLine.slice(0, lastSpaceIdx));
currentLine = currentLine.slice(lastSpaceIdx + 1) + char;
} else {
lines.push(currentLine);
currentLine = char;
}
} else {
currentLine = testLine;
}
}
if (currentLine) {
lines.push(currentLine);
}
}
return lines.length > 0 ? lines : [''];
}
export function wrapTextToString(
text: string,
fontSize: number,
fontFamily: number,
maxWidth: number,
): string {
const ctx = getMeasureContext();
ctx.font = getFontString(fontSize, fontFamily);
return wrapText(text, ctx, maxWidth).join('\n');
}
export function redrawTextBoundingBox(
textElement: DrawTextElement,
container: DrawElement | null,
scene: Scene,
): void {
const lineHeight = textElement.lineHeight || DEFAULT_LINE_HEIGHT;
if (container) {
const isArrow = container.type === 'arrow' || container.type === 'line';
const maxWidth = getBoundTextMaxWidth(container);
const metrics = measureText(
textElement.originalText,
textElement.fontSize,
textElement.fontFamily,
lineHeight,
isArrow ? undefined : maxWidth,
);
const wrappedText = metrics.wrappedText;
const textWidth = metrics.width;
const textHeight = metrics.height;
if (!isArrow) {
const maxHeight = getBoundTextMaxHeight(container, textElement);
if (textHeight > maxHeight) {
const nextHeight = computeContainerDimensionForBoundText(
textHeight,
container.type,
);
scene.mutateElement(container.id, { height: nextHeight } as Partial<DrawElement>);
}
if (textWidth > maxWidth) {
const nextWidth = computeContainerDimensionForBoundText(
textWidth,
container.type,
);
scene.mutateElement(container.id, { width: nextWidth } as Partial<DrawElement>);
}
}
const updatedContainer = scene.getElement(container.id) || container;
const pos = computeBoundTextPosition(updatedContainer, {
...textElement,
text: wrappedText,
width: textWidth,
height: textHeight,
} as DrawTextElement, scene);
scene.mutateElement(textElement.id, {
text: wrappedText,
x: pos.x,
y: pos.y,
width: textWidth,
height: textHeight,
angle: isArrow ? 0 : container.angle,
} as Partial<DrawElement>);
} else {
const metrics = measureText(
textElement.originalText,
textElement.fontSize,
textElement.fontFamily,
lineHeight,
textElement.autoResize ? undefined : textElement.width,
);
const updates: Partial<DrawTextElement> = {
text: metrics.wrappedText,
height: metrics.height,
};
if (textElement.autoResize) {
const widthDiff = metrics.width - textElement.width;
const heightDiff = metrics.height - textElement.height;
updates.width = metrics.width;
if (textElement.textAlign === 'center') {
updates.x = textElement.x - widthDiff / 2;
} else if (textElement.textAlign === 'right') {
updates.x = textElement.x - widthDiff;
}
if (textElement.verticalAlign === 'middle') {
updates.y = textElement.y - heightDiff / 2;
}
}
scene.mutateElement(textElement.id, updates as Partial<DrawElement>);
}
}
export function bindTextToContainer(
textElement: DrawTextElement,
container: DrawElement,
scene: Scene,
): void {
const isArrow = container.type === 'arrow' || container.type === 'line';
scene.mutateElement(textElement.id, {
containerId: container.id,
textAlign: 'center',
verticalAlign: 'middle',
angle: isArrow ? 0 : container.angle,
} as Partial<DrawElement>);
const existingBound = container.boundElements || [];
if (!existingBound.some((b) => b.id === textElement.id)) {
const newBound: BoundElement[] = [
...existingBound,
{ id: textElement.id, type: 'text' },
];
scene.mutateElement(container.id, {
boundElements: newBound,
} as Partial<DrawElement>);
}
const updatedContainer = scene.getElement(container.id) || container;
const updatedText = scene.getElement(textElement.id) as DrawTextElement;
if (updatedText) {
redrawTextBoundingBox(updatedText, updatedContainer, scene);
}
}
export function unbindTextFromContainer(
textElement: DrawTextElement,
scene: Scene,
): void {
if (!textElement.containerId) return;
const container = scene.getElement(textElement.containerId);
if (container && container.boundElements) {
const newBound = container.boundElements.filter((b) => b.id !== textElement.id);
scene.mutateElement(container.id, {
boundElements: newBound.length > 0 ? newBound : null,
} as Partial<DrawElement>);
}
const metrics = measureText(
textElement.originalText,
textElement.fontSize,
textElement.fontFamily,
textElement.lineHeight || DEFAULT_LINE_HEIGHT,
);
scene.mutateElement(textElement.id, {
containerId: null,
text: textElement.originalText,
width: metrics.width,
height: metrics.height,
autoResize: true,
} as Partial<DrawElement>);
}
export function handleBoundTextResize(
container: DrawElement,
scene: Scene,
): void {
const textEl = getBoundTextElement(container, scene);
if (!textEl) return;
redrawTextBoundingBox(textEl, container, scene);
}
@@ -1,157 +0,0 @@
import type { DrawElement, DrawLinearElement, DrawFreeDrawElement } from '../types';
import type { Bounds } from '../math/types';
import { pointRotate } from '../math/point';
/**
* Get the axis-aligned bounding box of an element, accounting for rotation.
*/
export function getElementBounds(element: DrawElement): Bounds {
if (isLinearLike(element)) {
return getLinearElementBounds(element as DrawLinearElement);
}
if (element.type === 'freedraw') {
return getFreeDrawBounds(element as DrawFreeDrawElement);
}
return getGenericElementBounds(element);
}
function getGenericElementBounds(element: DrawElement): Bounds {
const { x, y, width, height, angle } = element;
if (angle === 0) {
return [x, y, x + width, y + height];
}
const cx = x + width / 2;
const cy = y + height / 2;
const corners: [number, number][] = [
[x, y],
[x + width, y],
[x + width, y + height],
[x, y + height],
];
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const corner of corners) {
const rotated = pointRotate(corner, angle, [cx, cy]);
minX = Math.min(minX, rotated[0]);
minY = Math.min(minY, rotated[1]);
maxX = Math.max(maxX, rotated[0]);
maxY = Math.max(maxY, rotated[1]);
}
return [minX, minY, maxX, maxY];
}
/**
* Returns the unrotated local bounding box of a linear element's points.
* Does NOT account for element.angle.
*/
export function getLinearElementLocalBounds(element: DrawLinearElement): Bounds {
const { x, y, points } = element;
if (points.length === 0) {
return [x, y, x, y];
}
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const point of points) {
const px = x + point[0];
const py = y + point[1];
minX = Math.min(minX, px);
minY = Math.min(minY, py);
maxX = Math.max(maxX, px);
maxY = Math.max(maxY, py);
}
return [minX, minY, maxX, maxY];
}
function getLinearElementBounds(element: DrawLinearElement): Bounds {
const { x, y, points, angle } = element;
if (points.length === 0) {
return [x, y, x, y];
}
const cx = x + element.width / 2;
const cy = y + element.height / 2;
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const point of points) {
let px = x + point[0];
let py = y + point[1];
if (angle !== 0) {
const rotated = pointRotate([px, py], angle, [cx, cy]);
px = rotated[0];
py = rotated[1];
}
minX = Math.min(minX, px);
minY = Math.min(minY, py);
maxX = Math.max(maxX, px);
maxY = Math.max(maxY, py);
}
return [minX, minY, maxX, maxY];
}
function getFreeDrawBounds(element: DrawFreeDrawElement): Bounds {
const { x, y, points } = element;
if (points.length === 0) {
return [x, y, x, y];
}
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const point of points) {
const px = x + point[0];
const py = y + point[1];
minX = Math.min(minX, px);
minY = Math.min(minY, py);
maxX = Math.max(maxX, px);
maxY = Math.max(maxY, py);
}
return [minX, minY, maxX, maxY];
}
export function getCommonBounds(
elements: readonly DrawElement[],
): Bounds {
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const el of elements) {
const [x1, y1, x2, y2] = getElementBounds(el);
minX = Math.min(minX, x1);
minY = Math.min(minY, y1);
maxX = Math.max(maxX, x2);
maxY = Math.max(maxY, y2);
}
return [minX, minY, maxX, maxY];
}
export function getElementCenter(element: DrawElement): [number, number] {
const [x1, y1, x2, y2] = getElementBounds(element);
return [(x1 + x2) / 2, (y1 + y2) / 2];
}
function isLinearLike(element: DrawElement): boolean {
return element.type === 'line' || element.type === 'arrow';
}
@@ -1,230 +0,0 @@
import type { DrawElement, DrawLinearElement, DrawFreeDrawElement, GlobalPoint } from '../types';
import { getElementBounds } from './bounds';
import { pointRotate, pointDistance } from '../math/point';
import type { Point } from '../math/types';
import { lineClosestPoint } from '../math/line';
const HIT_THRESHOLD = 10;
/**
* Test if a scene point hits an element.
*/
export function hitTest(
element: DrawElement,
scenePoint: GlobalPoint,
zoom: number = 1,
): boolean {
const threshold = HIT_THRESHOLD / zoom;
if (element.type === 'line' || element.type === 'arrow') {
return hitTestLinear(element as DrawLinearElement, scenePoint, threshold);
}
if (element.type === 'freedraw') {
return hitTestFreeDraw(element as DrawFreeDrawElement, scenePoint, threshold);
}
if (element.type === 'diamond') {
return hitTestDiamond(element, scenePoint, threshold);
}
if (element.type === 'ellipse') {
return hitTestEllipse(element, scenePoint, threshold);
}
return hitTestGeneric(element, scenePoint, threshold);
}
function hitTestGeneric(
element: DrawElement,
scenePoint: GlobalPoint,
threshold: number,
): boolean {
const { x, y, width, height, angle } = element;
const cx = x + width / 2;
const cy = y + height / 2;
const rotatedPoint = angle !== 0
? pointRotate(scenePoint as unknown as Point, -angle, [cx, cy])
: (scenePoint as unknown as Point);
const px = rotatedPoint[0];
const py = rotatedPoint[1];
if (element.backgroundColor !== 'transparent') {
return (
px >= x - threshold &&
px <= x + width + threshold &&
py >= y - threshold &&
py <= y + height + threshold
);
}
const nearLeft = Math.abs(px - x) <= threshold && py >= y - threshold && py <= y + height + threshold;
const nearRight = Math.abs(px - (x + width)) <= threshold && py >= y - threshold && py <= y + height + threshold;
const nearTop = Math.abs(py - y) <= threshold && px >= x - threshold && px <= x + width + threshold;
const nearBottom = Math.abs(py - (y + height)) <= threshold && px >= x - threshold && px <= x + width + threshold;
return nearLeft || nearRight || nearTop || nearBottom;
}
function hitTestDiamond(
element: DrawElement,
scenePoint: GlobalPoint,
threshold: number,
): boolean {
const { x, y, width, height, angle } = element;
const cx = x + width / 2;
const cy = y + height / 2;
const rotatedPoint = angle !== 0
? pointRotate(scenePoint as unknown as Point, -angle, [cx, cy])
: (scenePoint as unknown as Point);
const px = rotatedPoint[0];
const py = rotatedPoint[1];
// A point is inside a diamond if |dx/a| + |dy/b| <= 1
// where a = width/2, b = height/2, dx/dy are relative to center
const a = width / 2;
const b = height / 2;
if (a === 0 || b === 0) return false;
const dx = Math.abs(px - cx);
const dy = Math.abs(py - cy);
const normalizedDist = dx / a + dy / b;
if (element.backgroundColor !== 'transparent') {
// filled: hit if inside diamond + threshold
const thresholdNorm = threshold / Math.min(a, b);
return normalizedDist <= 1 + thresholdNorm;
}
// stroke only: hit if near the diamond border
const thresholdNorm = threshold / Math.min(a, b);
return normalizedDist >= 1 - thresholdNorm && normalizedDist <= 1 + thresholdNorm;
}
function hitTestEllipse(
element: DrawElement,
scenePoint: GlobalPoint,
threshold: number,
): boolean {
const { x, y, width, height, angle } = element;
const cx = x + width / 2;
const cy = y + height / 2;
const rotatedPoint = angle !== 0
? pointRotate(scenePoint as unknown as Point, -angle, [cx, cy])
: (scenePoint as unknown as Point);
const px = rotatedPoint[0];
const py = rotatedPoint[1];
const a = width / 2;
const b = height / 2;
if (a === 0 || b === 0) return false;
const dx = px - cx;
const dy = py - cy;
if (element.backgroundColor !== 'transparent') {
const thresholdA = a + threshold;
const thresholdB = b + threshold;
return (dx * dx) / (thresholdA * thresholdA) + (dy * dy) / (thresholdB * thresholdB) <= 1;
}
const outerA = a + threshold;
const outerB = b + threshold;
const innerA = Math.max(a - threshold, 0);
const innerB = Math.max(b - threshold, 0);
const outerDist = (dx * dx) / (outerA * outerA) + (dy * dy) / (outerB * outerB);
const innerDist = innerA > 0 && innerB > 0
? (dx * dx) / (innerA * innerA) + (dy * dy) / (innerB * innerB)
: 2; // always "outside" inner if too small
return outerDist <= 1 && innerDist >= 1;
}
function hitTestLinear(
element: DrawLinearElement,
scenePoint: GlobalPoint,
threshold: number,
): boolean {
const { x, y, points, angle } = element;
const cx = x + element.width / 2;
const cy = y + element.height / 2;
const sp: Point = angle !== 0
? pointRotate([scenePoint[0], scenePoint[1]], -angle, [cx, cy])
: [scenePoint[0], scenePoint[1]];
for (let i = 0; i < points.length - 1; i++) {
const a: Point = [x + points[i]![0], y + points[i]![1]];
const b: Point = [x + points[i + 1]![0], y + points[i + 1]![1]];
const { distanceSq } = lineClosestPoint([a, b], sp);
if (Math.sqrt(distanceSq) <= threshold) {
return true;
}
}
return false;
}
function hitTestFreeDraw(
element: DrawFreeDrawElement,
scenePoint: GlobalPoint,
threshold: number,
): boolean {
const { x, y, points, strokeWidth } = element;
const sp: Point = [scenePoint[0], scenePoint[1]];
const visualThreshold = threshold + (strokeWidth * 4.25) / 2;
for (let i = 0; i < points.length - 1; i++) {
const a: Point = [x + points[i]![0], y + points[i]![1]];
const b: Point = [x + points[i + 1]![0], y + points[i + 1]![1]];
const { distanceSq } = lineClosestPoint([a, b], sp);
if (Math.sqrt(distanceSq) <= visualThreshold) {
return true;
}
}
if (points.length === 1) {
const d = pointDistance([x + points[0]![0], y + points[0]![1]], sp);
if (d <= visualThreshold) return true;
}
return false;
}
/**
* Test if an element is inside a selection box.
*/
export function isElementInsideBox(
element: DrawElement,
box: { x: number; y: number; width: number; height: number },
): boolean {
const [x1, y1, x2, y2] = getElementBounds(element);
const bx1 = Math.min(box.x, box.x + box.width);
const by1 = Math.min(box.y, box.y + box.height);
const bx2 = Math.max(box.x, box.x + box.width);
const by2 = Math.max(box.y, box.y + box.height);
return x1 >= bx1 && y1 >= by1 && x2 <= bx2 && y2 <= by2;
}
/**
* Test if an element overlaps a selection box.
*/
export function isElementOverlappingBox(
element: DrawElement,
box: { x: number; y: number; width: number; height: number },
): boolean {
const [x1, y1, x2, y2] = getElementBounds(element);
const bx1 = Math.min(box.x, box.x + box.width);
const by1 = Math.min(box.y, box.y + box.height);
const bx2 = Math.max(box.x, box.x + box.width);
const by2 = Math.max(box.y, box.y + box.height);
return x1 <= bx2 && x2 >= bx1 && y1 <= by2 && y2 >= by1;
}
@@ -1,41 +0,0 @@
import { nanoid } from 'nanoid';
import type { DrawElement } from '../types';
function randomInteger(): number {
return Math.floor(Math.random() * 2 ** 31);
}
export function duplicateElement(
element: DrawElement,
offsetX = 10,
offsetY = 10,
): DrawElement {
return {
...element,
id: nanoid(),
x: element.x + offsetX,
y: element.y + offsetY,
version: 1,
versionNonce: randomInteger(),
updated: Date.now(),
seed: Math.floor(Math.random() * 2 ** 31),
groupIds: [],
boundElements: null,
} as DrawElement;
}
export function duplicateElements(
elements: readonly DrawElement[],
offsetX = 10,
offsetY = 10,
): DrawElement[] {
const oldToNew = new Map<string, string>();
const duplicated = elements.map((el) => {
const newEl = duplicateElement(el, offsetX, offsetY);
oldToNew.set(el.id, newEl.id);
return newEl;
});
return duplicated;
}
@@ -1,214 +0,0 @@
import type { Scene } from '../core/scene';
import type {
DrawArrowElement,
DrawElement,
GlobalPoint,
LocalPoint,
} from '../types';
import { getElementBounds } from './bounds';
const DONGLE_LENGTH = 24;
const MIN_SEGMENT_LENGTH = 10;
type Direction = 'up' | 'down' | 'left' | 'right';
interface ElbowRouteContext {
startPoint: GlobalPoint;
endPoint: GlobalPoint;
startDir: Direction;
endDir: Direction;
startBounds: [number, number, number, number] | null;
endBounds: [number, number, number, number] | null;
}
/**
* Determine the heading direction from a point on an element's outline.
*/
function getHeadingFromElement(
point: GlobalPoint,
element: DrawElement | null,
): Direction {
if (!element) return 'right';
const cx = element.x + element.width / 2;
const cy = element.y + element.height / 2;
const dx = point[0] - cx;
const dy = point[1] - cy;
if (Math.abs(dx) > Math.abs(dy)) {
return dx > 0 ? 'right' : 'left';
}
return dy > 0 ? 'down' : 'up';
}
function getDirVector(dir: Direction): [number, number] {
switch (dir) {
case 'up': return [0, -1];
case 'down': return [0, 1];
case 'left': return [-1, 0];
case 'right': return [1, 0];
}
}
function oppositeDir(dir: Direction): Direction {
switch (dir) {
case 'up': return 'down';
case 'down': return 'up';
case 'left': return 'right';
case 'right': return 'left';
}
}
function isHorizontal(dir: Direction): boolean {
return dir === 'left' || dir === 'right';
}
/**
* Simplified elbow arrow routing.
* Produces an orthogonal path with at most 5 segments (L, Z, or U shape).
*/
function routeElbow(ctx: ElbowRouteContext): GlobalPoint[] {
const { startPoint, endPoint, startDir, endDir } = ctx;
const [sx, sy] = startPoint;
const [ex, ey] = endPoint;
const sv = getDirVector(startDir);
const ev = getDirVector(oppositeDir(endDir));
const dongleStart: GlobalPoint = [
sx + sv[0] * DONGLE_LENGTH,
sy + sv[1] * DONGLE_LENGTH,
] as GlobalPoint;
const dongleEnd: GlobalPoint = [
ex + ev[0] * DONGLE_LENGTH,
ey + ev[1] * DONGLE_LENGTH,
] as GlobalPoint;
const [dsx, dsy] = dongleStart;
const [dex, dey] = dongleEnd;
if (isHorizontal(startDir) && !isHorizontal(endDir)) {
return [startPoint, dongleStart, [dsx, dey] as GlobalPoint, dongleEnd, endPoint];
}
if (!isHorizontal(startDir) && isHorizontal(endDir)) {
return [startPoint, dongleStart, [dex, dsy] as GlobalPoint, dongleEnd, endPoint];
}
if (isHorizontal(startDir) && isHorizontal(endDir)) {
const midX = (dsx + dex) / 2;
return [
startPoint,
dongleStart,
[midX, dsy] as GlobalPoint,
[midX, dey] as GlobalPoint,
dongleEnd,
endPoint,
];
}
const midY = (dsy + dey) / 2;
return [
startPoint,
dongleStart,
[dsx, midY] as GlobalPoint,
[dex, midY] as GlobalPoint,
dongleEnd,
endPoint,
];
}
/**
* Remove redundant collinear points from the path.
*/
function simplifyPath(points: GlobalPoint[]): GlobalPoint[] {
if (points.length <= 2) return points;
const result: GlobalPoint[] = [points[0]!];
for (let i = 1; i < points.length - 1; i++) {
const prev = result[result.length - 1]!;
const curr = points[i]!;
const next = points[i + 1]!;
const sameX = Math.abs(prev[0] - curr[0]) < 0.5 && Math.abs(curr[0] - next[0]) < 0.5;
const sameY = Math.abs(prev[1] - curr[1]) < 0.5 && Math.abs(curr[1] - next[1]) < 0.5;
if (!sameX && !sameY) {
result.push(curr);
} else if (sameX || sameY) {
// skip collinear
} else {
result.push(curr);
}
}
result.push(points[points.length - 1]!);
return result;
}
/**
* Calculate elbow arrow points for a given arrow element.
*/
export function calculateElbowArrowPoints(
arrow: DrawArrowElement,
scene: Scene,
): LocalPoint[] {
const firstPoint = arrow.points[0]!;
const lastPoint = arrow.points[arrow.points.length - 1]!;
const startGlobal: GlobalPoint = [
arrow.x + firstPoint[0],
arrow.y + firstPoint[1],
] as GlobalPoint;
const endGlobal: GlobalPoint = [
arrow.x + lastPoint[0],
arrow.y + lastPoint[1],
] as GlobalPoint;
let startElement: DrawElement | null = null;
let endElement: DrawElement | null = null;
let startBounds: [number, number, number, number] | null = null;
let endBounds: [number, number, number, number] | null = null;
if (arrow.startBinding) {
startElement = scene.getElement(arrow.startBinding.elementId) || null;
if (startElement) startBounds = getElementBounds(startElement) as [number, number, number, number];
}
if (arrow.endBinding) {
endElement = scene.getElement(arrow.endBinding.elementId) || null;
if (endElement) endBounds = getElementBounds(endElement) as [number, number, number, number];
}
const startDir = getHeadingFromElement(startGlobal, startElement);
const endDir = getHeadingFromElement(endGlobal, endElement);
const ctx: ElbowRouteContext = {
startPoint: startGlobal,
endPoint: endGlobal,
startDir,
endDir,
startBounds,
endBounds,
};
const globalPath = routeElbow(ctx);
const simplified = simplifyPath(globalPath);
return simplified.map(
(p) => [p[0] - arrow.x, p[1] - arrow.y] as LocalPoint,
);
}
/**
* Update an elbow arrow's points after binding changes or element moves.
*/
export function updateElbowArrowPoints(
arrow: DrawArrowElement,
scene: Scene,
): void {
if (!arrow.elbowed) return;
const newPoints = calculateElbowArrowPoints(arrow, scene);
scene.mutateElement(arrow.id, { points: newPoints } as Partial<DrawElement>);
}
@@ -1,96 +0,0 @@
import type { Scene } from '../core/scene';
import type {
DrawElement,
DrawLinearElement,
DrawArrowElement,
LocalPoint,
} from '../types';
import { getCommonBounds } from './bounds';
export function flipElements(
selectedElements: readonly DrawElement[],
direction: 'horizontal' | 'vertical',
scene: Scene,
): void {
if (selectedElements.length === 0) return;
if (allAreBoundArrows(selectedElements)) {
for (const el of selectedElements) {
const arrow = el as DrawArrowElement;
scene.mutateElement(arrow.id, {
startArrowhead: arrow.endArrowhead,
endArrowhead: arrow.startArrowhead,
} as Partial<DrawElement>);
}
return;
}
const [x1, y1, x2, y2] = getCommonBounds(selectedElements);
const midX = (x1 + x2) / 2;
const midY = (y1 + y2) / 2;
for (const el of selectedElements) {
if (isLinearLike(el)) {
flipLinearElement(el as DrawLinearElement, direction, midX, midY, scene);
} else {
flipGenericElement(el, direction, midX, midY, scene);
}
}
}
function flipGenericElement(
element: DrawElement,
direction: 'horizontal' | 'vertical',
midX: number,
midY: number,
scene: Scene,
): void {
if (direction === 'horizontal') {
const newX = 2 * midX - element.x - element.width;
scene.mutateElement(element.id, { x: newX } as Partial<DrawElement>);
} else {
const newY = 2 * midY - element.y - element.height;
scene.mutateElement(element.id, { y: newY } as Partial<DrawElement>);
}
}
function flipLinearElement(
element: DrawLinearElement,
direction: 'horizontal' | 'vertical',
midX: number,
midY: number,
scene: Scene,
): void {
const newPoints = element.points.map((p) => {
if (direction === 'horizontal') {
return [-p[0], p[1]] as LocalPoint;
}
return [p[0], -p[1]] as LocalPoint;
});
if (direction === 'horizontal') {
const newX = 2 * midX - element.x - element.width;
scene.mutateElement(element.id, {
x: newX,
points: newPoints,
} as Partial<DrawElement>);
} else {
const newY = 2 * midY - element.y - element.height;
scene.mutateElement(element.id, {
y: newY,
points: newPoints,
} as Partial<DrawElement>);
}
}
function allAreBoundArrows(elements: readonly DrawElement[]): boolean {
return elements.every((el) => {
if (el.type !== 'arrow') return false;
const arrow = el as DrawArrowElement;
return arrow.startBinding != null || arrow.endBinding != null;
});
}
function isLinearLike(element: DrawElement): boolean {
return element.type === 'line' || element.type === 'arrow';
}
@@ -1,137 +0,0 @@
import type { Scene } from '../core/scene';
import type { DrawElement, DrawFrameElement, NonDeletedDrawElement } from '../types';
import { getElementBounds } from './bounds';
export function isFrameElement(element: DrawElement): element is DrawFrameElement {
return element.type === 'frame';
}
export function getFrameChildren(
frame: DrawFrameElement,
elements: readonly DrawElement[],
): DrawElement[] {
return elements.filter((el) => !el.isDeleted && el.frameId === frame.id);
}
export function addElementsToFrame(
elements: readonly DrawElement[],
frame: DrawFrameElement,
scene: Scene,
): void {
for (const el of elements) {
if (el.id === frame.id || el.type === 'frame') continue;
if (el.frameId !== frame.id) {
scene.mutateElement(el.id, { frameId: frame.id } as Partial<DrawElement>);
}
}
}
export function removeElementsFromFrame(
elements: readonly DrawElement[],
scene: Scene,
): void {
for (const el of elements) {
if (el.frameId) {
scene.mutateElement(el.id, { frameId: null } as Partial<DrawElement>);
}
}
}
export function removeAllElementsFromFrame(
frame: DrawFrameElement,
allElements: readonly DrawElement[],
scene: Scene,
): void {
const children = getFrameChildren(frame, allElements);
removeElementsFromFrame(children, scene);
}
/**
* Check if an element's bounds are completely inside a frame's bounds.
*/
export function isElementInFrame(
element: DrawElement,
frame: DrawFrameElement,
): boolean {
const [ex1, ey1, ex2, ey2] = getElementBounds(element);
const [fx1, fy1, fx2, fy2] = getElementBounds(frame);
return ex1 >= fx1 && ey1 >= fy1 && ex2 <= fx2 && ey2 <= fy2;
}
/**
* Check if an element overlaps with a frame.
*/
export function elementOverlapsWithFrame(
element: DrawElement,
frame: DrawFrameElement,
): boolean {
const [ex1, ey1, ex2, ey2] = getElementBounds(element);
const [fx1, fy1, fx2, fy2] = getElementBounds(frame);
return ex1 <= fx2 && ex2 >= fx1 && ey1 <= fy2 && ey2 >= fy1;
}
/**
* Get all non-frame elements completely inside a frame's bounds.
*/
export function getElementsInFrame(
frame: DrawFrameElement,
elements: readonly NonDeletedDrawElement[],
): NonDeletedDrawElement[] {
return elements.filter(
(el) => el.id !== frame.id && el.type !== 'frame' && isElementInFrame(el, frame),
);
}
/**
* After dragging elements, update their frame membership.
*/
export function updateFrameMembershipOnDrag(
draggedElements: readonly DrawElement[],
allElements: readonly NonDeletedDrawElement[],
scene: Scene,
): void {
const frames = allElements.filter(
(el): el is DrawFrameElement & { isDeleted: false } => el.type === 'frame',
);
for (const el of draggedElements) {
if (el.type === 'frame') continue;
let newFrameId: string | null = null;
for (const frame of frames) {
if (isElementInFrame(el, frame)) {
newFrameId = frame.id;
break;
}
}
if (el.frameId !== newFrameId) {
scene.mutateElement(el.id, { frameId: newFrameId } as Partial<DrawElement>);
}
}
}
/**
* Whether frame clipping should be applied for rendering.
*/
export function shouldApplyFrameClip(
element: DrawElement,
frameRendering: { enabled: boolean; clip: boolean },
): boolean {
return (
frameRendering.enabled &&
frameRendering.clip &&
element.frameId != null
);
}
/**
* Get all frames from elements.
*/
export function getAllFrames(
elements: readonly DrawElement[],
): DrawFrameElement[] {
return elements.filter(
(el): el is DrawFrameElement => el.type === 'frame' && !el.isDeleted,
);
}
@@ -1,145 +0,0 @@
import { nanoid } from 'nanoid';
import type { Scene } from '../core/scene';
import type { DrawElement, NonDeletedDrawElement } from '../types';
export function generateGroupId(): string {
return nanoid();
}
export function getElementsInGroup(
elements: readonly DrawElement[],
groupId: string,
): DrawElement[] {
return elements.filter(
(el) => !el.isDeleted && el.groupIds.includes(groupId),
);
}
/**
* Split selected elements into "maximum groups":
* elements sharing the outermost groupId are grouped together;
* ungrouped elements each form their own group.
*/
export function getMaximumGroups(
elements: readonly DrawElement[],
): DrawElement[][] {
const groups = new Map<string, DrawElement[]>();
const ungrouped: DrawElement[][] = [];
for (const el of elements) {
if (el.groupIds.length > 0) {
const outerGroupId = el.groupIds[el.groupIds.length - 1]!;
let group = groups.get(outerGroupId);
if (!group) {
group = [];
groups.set(outerGroupId, group);
}
group.push(el);
} else {
ungrouped.push([el]);
}
}
return [...groups.values(), ...ungrouped];
}
export function addToGroup(
prevGroupIds: readonly string[],
newGroupId: string,
editingGroupId?: string | null,
): string[] {
if (editingGroupId) {
const idx = prevGroupIds.indexOf(editingGroupId);
if (idx >= 0) {
return [
...prevGroupIds.slice(0, idx),
newGroupId,
...prevGroupIds.slice(idx),
];
}
}
return [...prevGroupIds, newGroupId];
}
export function removeFromGroup(
groupIds: readonly string[],
groupId: string,
): string[] {
return groupIds.filter((id) => id !== groupId);
}
/**
* Expand selection to include all elements sharing a group with any selected element.
*/
export function selectGroupsForSelectedElements(
selectedIds: Record<string, true>,
elements: readonly NonDeletedDrawElement[],
editingGroupId?: string | null,
): { selectedElementIds: Record<string, true>; selectedGroupIds: Record<string, boolean> } {
const newSelectedIds: Record<string, true> = { ...selectedIds };
const selectedGroupIds: Record<string, boolean> = {};
for (const el of elements) {
if (!selectedIds[el.id]) continue;
for (const gid of el.groupIds) {
if (gid === editingGroupId) break;
selectedGroupIds[gid] = true;
}
}
for (const el of elements) {
for (const gid of el.groupIds) {
if (gid === editingGroupId) break;
if (selectedGroupIds[gid]) {
newSelectedIds[el.id] = true;
break;
}
}
}
return { selectedElementIds: newSelectedIds, selectedGroupIds };
}
export function groupElements(
selectedElements: readonly DrawElement[],
scene: Scene,
editingGroupId?: string | null,
): string {
const groupId = generateGroupId();
for (const el of selectedElements) {
const newGroupIds = addToGroup(el.groupIds, groupId, editingGroupId);
scene.mutateElement(el.id, { groupIds: newGroupIds } as Partial<DrawElement>);
}
return groupId;
}
export function ungroupElements(
elements: readonly DrawElement[],
scene: Scene,
groupId: string,
): void {
for (const el of elements) {
if (el.groupIds.includes(groupId)) {
const newGroupIds = removeFromGroup(el.groupIds, groupId);
scene.mutateElement(el.id, { groupIds: newGroupIds } as Partial<DrawElement>);
}
}
}
/**
* Get the outermost group ID for an element (the one that should be selected
* when clicking on the element).
*/
export function getOutermostGroupId(
element: DrawElement,
editingGroupId?: string | null,
): string | null {
if (element.groupIds.length === 0) return null;
for (let i = element.groupIds.length - 1; i >= 0; i--) {
if (element.groupIds[i] === editingGroupId) {
return i > 0 ? element.groupIds[i - 1]! : null;
}
}
return element.groupIds[element.groupIds.length - 1]!;
}
@@ -1,198 +0,0 @@
import type {
DrawLinearElement,
DrawElement,
LocalPoint,
GlobalPoint,
LinearElementEditorState,
} from '../types';
import {
getLinearPointIndexAtPosition,
getLinearMidpointIndexAtPosition,
} from './transform-handles';
export function createLinearEditorState(
elementId: string,
): LinearElementEditorState {
return {
elementId,
selectedPointIndices: [],
isDragging: false,
dragStartPoint: null,
lastDragPoint: null,
};
}
export function isLinearElement(el: DrawElement): el is DrawLinearElement {
return el.type === 'line' || el.type === 'arrow';
}
export function getPointGlobalCoords(
element: DrawLinearElement,
pointIndex: number,
): GlobalPoint {
const pt = element.points[pointIndex];
if (!pt) return [element.x, element.y] as GlobalPoint;
return [element.x + pt[0], element.y + pt[1]] as GlobalPoint;
}
export function handleLinearEditorPointerDown(
state: LinearElementEditorState,
element: DrawLinearElement,
sceneX: number,
sceneY: number,
zoom: number,
): { action: 'drag-point' | 'add-midpoint' | 'none'; pointIndex?: number } {
const hitIndex = getLinearPointIndexAtPosition(element, sceneX, sceneY, zoom);
if (hitIndex !== null) {
state.selectedPointIndices = [hitIndex];
state.isDragging = true;
state.dragStartPoint = [sceneX, sceneY] as GlobalPoint;
state.lastDragPoint = [sceneX, sceneY] as GlobalPoint;
return { action: 'drag-point', pointIndex: hitIndex };
}
const midIndex = getLinearMidpointIndexAtPosition(element, sceneX, sceneY, zoom);
if (midIndex !== null) {
return { action: 'add-midpoint', pointIndex: midIndex };
}
return { action: 'none' };
}
export function handleLinearEditorPointerMove(
state: LinearElementEditorState,
element: DrawLinearElement,
sceneX: number,
sceneY: number,
shiftKey: boolean,
): DrawLinearElement | null {
if (!state.isDragging || state.selectedPointIndices.length === 0) return null;
const dx = sceneX - (state.lastDragPoint?.[0] ?? sceneX);
const dy = sceneY - (state.lastDragPoint?.[1] ?? sceneY);
state.lastDragPoint = [sceneX, sceneY] as GlobalPoint;
const newPoints = [...element.points] as LocalPoint[];
for (const idx of state.selectedPointIndices) {
const pt = newPoints[idx];
if (!pt) continue;
let newX = pt[0] + dx;
let newY = pt[1] + dy;
if (shiftKey && element.points.length >= 2) {
const prevIdx = idx > 0 ? idx - 1 : idx + 1;
const prevPt = newPoints[prevIdx];
if (prevPt) {
const angle = Math.atan2(newY - prevPt[1], newX - prevPt[0]);
const snapped = Math.round(angle / (Math.PI / 4)) * (Math.PI / 4);
const dist = Math.hypot(newX - prevPt[0], newY - prevPt[1]);
newX = prevPt[0] + Math.cos(snapped) * dist;
newY = prevPt[1] + Math.sin(snapped) * dist;
}
}
newPoints[idx] = [newX, newY] as LocalPoint;
}
const normalized = normalizePoints(newPoints);
return {
...element,
x: element.x + normalized.offsetX,
y: element.y + normalized.offsetY,
points: normalized.points,
width: normalized.width,
height: normalized.height,
} as DrawLinearElement;
}
export function handleLinearEditorPointerUp(
state: LinearElementEditorState,
): void {
state.isDragging = false;
state.dragStartPoint = null;
state.lastDragPoint = null;
}
export function addPointAtMidpoint(
element: DrawLinearElement,
segmentIndex: number,
): { updatedElement: DrawLinearElement; newPointIndex: number } {
const p1 = element.points[segmentIndex]!;
const p2 = element.points[segmentIndex + 1]!;
const midPoint: LocalPoint = [
(p1[0] + p2[0]) / 2,
(p1[1] + p2[1]) / 2,
] as LocalPoint;
const newPoints = [...element.points];
newPoints.splice(segmentIndex + 1, 0, midPoint);
return {
updatedElement: {
...element,
points: newPoints as readonly LocalPoint[],
} as DrawLinearElement,
newPointIndex: segmentIndex + 1,
};
}
export function deletePoints(
element: DrawLinearElement,
indices: number[],
): DrawLinearElement | null {
if (element.points.length - indices.length < 2) return null;
const indexSet = new Set(indices);
const newPoints = element.points.filter(
(_, i) => !indexSet.has(i),
) as LocalPoint[];
const normalized = normalizePoints(newPoints);
return {
...element,
x: element.x + normalized.offsetX,
y: element.y + normalized.offsetY,
points: normalized.points,
width: normalized.width,
height: normalized.height,
} as DrawLinearElement;
}
function normalizePoints(points: LocalPoint[]): {
points: LocalPoint[];
offsetX: number;
offsetY: number;
width: number;
height: number;
} {
if (points.length === 0) {
return { points: [], offsetX: 0, offsetY: 0, width: 0, height: 0 };
}
const first = points[0]!;
const offsetX = first[0];
const offsetY = first[1];
const normalized = points.map(
(p) => [p[0] - offsetX, p[1] - offsetY] as LocalPoint,
);
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const p of normalized) {
minX = Math.min(minX, p[0]);
minY = Math.min(minY, p[1]);
maxX = Math.max(maxX, p[0]);
maxY = Math.max(maxY, p[1]);
}
return {
points: normalized,
offsetX,
offsetY,
width: Math.abs(maxX - minX),
height: Math.abs(maxY - minY),
};
}
@@ -1,33 +0,0 @@
import type { DrawElement } from '../types';
function randomInteger(): number {
return Math.floor(Math.random() * 2 ** 31);
}
/**
* Immutable element update. Returns a new element with bumped version.
*/
export function mutateElement<T extends DrawElement>(
element: T,
updates: Partial<T>,
): T {
return {
...element,
...updates,
version: element.version + 1,
versionNonce: randomInteger(),
updated: Date.now(),
};
}
/**
* Bump version without changing properties.
*/
export function bumpVersion<T extends DrawElement>(element: T): T {
return {
...element,
version: element.version + 1,
versionNonce: randomInteger(),
updated: Date.now(),
};
}
@@ -1,294 +0,0 @@
import { nanoid } from 'nanoid';
import type {
DrawElement,
DrawRectangleElement,
DrawEllipseElement,
DrawDiamondElement,
DrawTextElement,
DrawLinearElement,
DrawArrowElement,
DrawFreeDrawElement,
DrawImageElement,
DrawFrameElement,
DrawSelectionElement,
FillStyle,
StrokeStyle,
Radians,
LocalPoint,
Arrowhead,
TextAlign,
VerticalAlign,
FractionalIndex,
FileId,
} from '../types';
import {
DEFAULT_ELEMENT_STROKE_COLOR,
DEFAULT_ELEMENT_BACKGROUND_COLOR,
DEFAULT_ELEMENT_FILL_STYLE,
DEFAULT_ELEMENT_STROKE_WIDTH,
DEFAULT_ELEMENT_STROKE_STYLE,
DEFAULT_ELEMENT_ROUGHNESS,
DEFAULT_ELEMENT_OPACITY,
DEFAULT_ELEMENT_ROUNDNESS,
DEFAULT_FONT_SIZE,
DEFAULT_FONT_FAMILY,
DEFAULT_TEXT_ALIGN,
DEFAULT_VERTICAL_ALIGN,
DEFAULT_LINE_HEIGHT,
DEFAULT_END_ARROWHEAD,
ANGLE_ZERO,
FONT_FAMILY_FALLBACKS,
ROUNDNESS,
} from '../constants';
function randomInteger(): number {
return Math.floor(Math.random() * 2 ** 31);
}
function randomSeed(): number {
return Math.floor(Math.random() * 2 ** 31);
}
interface CommonOpts {
x?: number;
y?: number;
width?: number;
height?: number;
strokeColor?: string;
backgroundColor?: string;
fillStyle?: FillStyle;
strokeWidth?: number;
strokeStyle?: StrokeStyle;
roughness?: number;
opacity?: number;
angle?: Radians;
locked?: boolean;
groupIds?: string[];
frameId?: string | null;
}
function baseElement(
type: DrawElement['type'],
opts: CommonOpts = {},
): Omit<DrawElement, 'type'> & { type: string } {
return {
id: nanoid(),
type,
x: opts.x ?? 0,
y: opts.y ?? 0,
width: opts.width ?? 0,
height: opts.height ?? 0,
angle: opts.angle ?? ANGLE_ZERO,
strokeColor: opts.strokeColor ?? DEFAULT_ELEMENT_STROKE_COLOR,
backgroundColor: opts.backgroundColor ?? DEFAULT_ELEMENT_BACKGROUND_COLOR,
fillStyle: opts.fillStyle ?? DEFAULT_ELEMENT_FILL_STYLE,
strokeWidth: opts.strokeWidth ?? DEFAULT_ELEMENT_STROKE_WIDTH,
strokeStyle: opts.strokeStyle ?? DEFAULT_ELEMENT_STROKE_STYLE,
roughness: opts.roughness ?? DEFAULT_ELEMENT_ROUGHNESS,
opacity: opts.opacity ?? DEFAULT_ELEMENT_OPACITY,
roundness: DEFAULT_ELEMENT_ROUNDNESS,
seed: randomSeed(),
version: 1,
versionNonce: randomInteger(),
index: null as FractionalIndex | null,
isDeleted: false,
groupIds: opts.groupIds ?? [],
frameId: opts.frameId ?? null,
boundElements: null,
link: null,
locked: opts.locked ?? false,
updated: Date.now(),
};
}
export function newRectangleElement(
opts: CommonOpts = {},
): DrawRectangleElement {
return { ...baseElement('rectangle', opts), type: 'rectangle' } as DrawRectangleElement;
}
export function newEllipseElement(opts: CommonOpts = {}): DrawEllipseElement {
return { ...baseElement('ellipse', opts), type: 'ellipse' } as DrawEllipseElement;
}
export function newDiamondElement(opts: CommonOpts = {}): DrawDiamondElement {
return { ...baseElement('diamond', opts), type: 'diamond' } as DrawDiamondElement;
}
export function newSelectionElement(
opts: CommonOpts = {},
): DrawSelectionElement {
return {
...baseElement('selection', opts),
type: 'selection',
} as DrawSelectionElement;
}
export interface TextOpts extends CommonOpts {
text?: string;
fontSize?: number;
fontFamily?: number;
textAlign?: TextAlign;
verticalAlign?: VerticalAlign;
containerId?: string | null;
lineHeight?: number;
autoResize?: boolean;
}
export function newTextElement(opts: TextOpts = {}): DrawTextElement {
const text = opts.text ?? '';
const fontSize = opts.fontSize ?? DEFAULT_FONT_SIZE;
const fontFamily = opts.fontFamily ?? DEFAULT_FONT_FAMILY;
const lineHeight = opts.lineHeight ?? DEFAULT_LINE_HEIGHT;
let width = opts.width ?? 0;
let height = opts.height ?? 0;
if (text && (width === 0 || height === 0)) {
try {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (ctx) {
const ff = FONT_FAMILY_FALLBACKS[fontFamily] || 'sans-serif';
ctx.font = `${fontSize}px ${ff}`;
const lines = text.split('\n');
let maxW = 0;
for (const line of lines) {
maxW = Math.max(maxW, ctx.measureText(line || ' ').width);
}
if (width === 0) width = Math.ceil(maxW);
if (height === 0) height = Math.ceil(lines.length * fontSize * lineHeight);
}
} catch {
const lineCount = text.split('\n').length;
height = lineCount * fontSize * lineHeight;
width = text.length * fontSize * 0.6;
}
}
if (height === 0) {
height = Math.ceil(fontSize * lineHeight);
}
return {
...baseElement('text', { ...opts, width, height }),
type: 'text',
text,
originalText: text,
fontSize,
fontFamily,
textAlign: (opts.textAlign ?? DEFAULT_TEXT_ALIGN) as TextAlign,
verticalAlign: (opts.verticalAlign ?? DEFAULT_VERTICAL_ALIGN) as VerticalAlign,
containerId: opts.containerId ?? null,
autoResize: opts.autoResize ?? true,
lineHeight,
} as DrawTextElement;
}
export interface LinearOpts extends CommonOpts {
points?: LocalPoint[];
startArrowhead?: Arrowhead | null;
endArrowhead?: Arrowhead | null;
}
export function newLineElement(opts: LinearOpts = {}): DrawLinearElement {
return {
...baseElement('line', opts),
type: 'line',
roundness: { type: ROUNDNESS.PROPORTIONAL_RADIUS },
points: opts.points ?? ([[0, 0] as LocalPoint, [0, 0] as LocalPoint]),
startBinding: null,
endBinding: null,
startArrowhead: opts.startArrowhead ?? null,
endArrowhead: opts.endArrowhead ?? null,
} as DrawLinearElement;
}
export function newArrowElement(opts: LinearOpts = {}): DrawArrowElement {
return {
...baseElement('arrow', opts),
type: 'arrow',
roundness: { type: ROUNDNESS.PROPORTIONAL_RADIUS },
points: opts.points ?? ([[0, 0] as LocalPoint, [0, 0] as LocalPoint]),
startBinding: null,
endBinding: null,
startArrowhead: opts.startArrowhead ?? null,
endArrowhead: opts.endArrowhead ?? DEFAULT_END_ARROWHEAD,
elbowed: false,
} as DrawArrowElement;
}
export interface FreeDrawOpts extends CommonOpts {
points?: LocalPoint[];
pressures?: number[];
simulatePressure?: boolean;
}
export function newFreeDrawElement(
opts: FreeDrawOpts = {},
): DrawFreeDrawElement {
return {
...baseElement('freedraw', opts),
type: 'freedraw',
points: opts.points ?? [],
pressures: opts.pressures ?? [],
simulatePressure: opts.simulatePressure ?? true,
} as DrawFreeDrawElement;
}
export interface ImageOpts extends CommonOpts {
fileId?: FileId | null;
}
export function newImageElement(opts: ImageOpts = {}): DrawImageElement {
return {
...baseElement('image', opts),
type: 'image',
fileId: opts.fileId ?? null,
status: 'pending',
scale: [1, 1],
} as DrawImageElement;
}
export function newFrameElement(
opts: CommonOpts & { name?: string } = {},
): DrawFrameElement {
return {
...baseElement('frame', opts),
type: 'frame',
name: opts.name ?? null,
width: opts.width ?? 800,
height: opts.height ?? 600,
} as DrawFrameElement;
}
export function newElementByType(
type: DrawElement['type'],
opts: CommonOpts = {},
): DrawElement {
switch (type) {
case 'rectangle':
return newRectangleElement(opts);
case 'ellipse':
return newEllipseElement(opts);
case 'diamond':
return newDiamondElement(opts);
case 'text':
return newTextElement(opts as TextOpts);
case 'line':
return newLineElement(opts as LinearOpts);
case 'arrow':
return newArrowElement(opts as LinearOpts);
case 'freedraw':
return newFreeDrawElement(opts as FreeDrawOpts);
case 'image':
return newImageElement(opts as ImageOpts);
case 'frame':
return newFrameElement(opts);
case 'selection':
return newSelectionElement(opts);
default:
return newRectangleElement(opts);
}
}
@@ -1,479 +0,0 @@
import rough from 'roughjs';
import type { RoughCanvas } from 'roughjs/bin/canvas';
import type { Drawable } from 'roughjs/bin/core';
import getStroke from 'perfect-freehand';
import type {
DrawElement,
DrawLinearElement,
DrawArrowElement,
DrawFreeDrawElement,
Arrowhead,
} from '../types';
import {
ROUNDNESS,
DEFAULT_PROPORTIONAL_RADIUS,
DEFAULT_ADAPTIVE_RADIUS,
} from '../constants';
const cache = new Map<string, Drawable>();
function getCacheKey(element: DrawElement): string {
const r = element.roundness ? element.roundness.type : 0;
return `${element.id}_${element.version}_${element.seed}_${r}`;
}
function getLineDash(strokeStyle: string): number[] | undefined {
switch (strokeStyle) {
case 'dashed':
return [12, 8];
case 'dotted':
return [3, 6];
default:
return undefined;
}
}
function getRoughOptions(element: DrawElement) {
return {
seed: element.seed,
roughness: element.roughness,
fill:
element.backgroundColor !== 'transparent'
? element.backgroundColor
: undefined,
fillStyle: element.fillStyle as string,
stroke: element.strokeColor,
strokeWidth: element.strokeWidth,
strokeLineDash: getLineDash(element.strokeStyle),
};
}
function getCornerRadius(x: number, element: DrawElement): number {
if (
element.roundness?.type === ROUNDNESS.PROPORTIONAL_RADIUS ||
element.roundness?.type === ROUNDNESS.LEGACY
) {
return x * DEFAULT_PROPORTIONAL_RADIUS;
}
if (element.roundness?.type === ROUNDNESS.ADAPTIVE_RADIUS) {
const fixedRadiusSize =
element.roundness?.value ?? DEFAULT_ADAPTIVE_RADIUS;
const CUTOFF_SIZE = fixedRadiusSize / DEFAULT_PROPORTIONAL_RADIUS;
if (x <= CUTOFF_SIZE) {
return x * DEFAULT_PROPORTIONAL_RADIUS;
}
return fixedRadiusSize;
}
return 0;
}
export function generateRoughDrawable(element: DrawElement): Drawable | null {
const key = getCacheKey(element);
const cached = cache.get(key);
if (cached) return cached;
const gen = rough.generator();
const opts = getRoughOptions(element);
let drawable: Drawable | null = null;
switch (element.type) {
case 'rectangle': {
if (element.roundness) {
const w = element.width;
const h = element.height;
const r = getCornerRadius(Math.min(w, h), element);
drawable = gen.path(
`M ${r} 0 L ${w - r} 0 Q ${w} 0, ${w} ${r} L ${w} ${h - r} Q ${w} ${h}, ${w - r} ${h} L ${r} ${h} Q 0 ${h}, 0 ${h - r} L 0 ${r} Q 0 0, ${r} 0`,
opts,
);
} else {
drawable = gen.rectangle(0, 0, element.width, element.height, opts);
}
break;
}
case 'ellipse':
drawable = gen.ellipse(
element.width / 2,
element.height / 2,
element.width,
element.height,
opts,
);
break;
case 'diamond': {
const w = element.width;
const h = element.height;
const topX = Math.floor(w / 2) + 1;
const topY = 0;
const rightX = w;
const rightY = Math.floor(h / 2) + 1;
const bottomX = topX;
const bottomY = h;
const leftX = 0;
const leftY = rightY;
if (element.roundness) {
const verticalRadius = getCornerRadius(
Math.abs(topX - leftX),
element,
);
const horizontalRadius = getCornerRadius(
Math.abs(rightY - topY),
element,
);
drawable = gen.path(
`M ${topX + verticalRadius} ${topY + horizontalRadius} L ${rightX - verticalRadius} ${rightY - horizontalRadius} C ${rightX} ${rightY}, ${rightX} ${rightY}, ${rightX - verticalRadius} ${rightY + horizontalRadius} L ${bottomX + verticalRadius} ${bottomY - horizontalRadius} C ${bottomX} ${bottomY}, ${bottomX} ${bottomY}, ${bottomX - verticalRadius} ${bottomY - horizontalRadius} L ${leftX + verticalRadius} ${leftY + horizontalRadius} C ${leftX} ${leftY}, ${leftX} ${leftY}, ${leftX + verticalRadius} ${leftY - horizontalRadius} L ${topX - verticalRadius} ${topY + horizontalRadius} C ${topX} ${topY}, ${topX} ${topY}, ${topX + verticalRadius} ${topY + horizontalRadius}`,
opts,
);
} else {
drawable = gen.polygon(
[
[topX, topY],
[rightX, rightY],
[bottomX, bottomY],
[leftX, leftY],
],
opts,
);
}
break;
}
case 'line':
case 'arrow': {
const linear = element as DrawLinearElement;
if (linear.points.length >= 2) {
const pts = linear.points.map((p) => [p[0], p[1]] as [number, number]);
if (pts.length === 2) {
drawable = gen.line(pts[0]![0], pts[0]![1], pts[1]![0], pts[1]![1], opts);
} else if (element.roundness) {
drawable = gen.curve(pts, opts);
} else {
drawable = gen.linearPath(pts, opts);
}
}
break;
}
default:
return null;
}
if (drawable) {
cache.set(key, drawable);
if (cache.size > 5000) {
const firstKey = cache.keys().next().value;
if (firstKey) cache.delete(firstKey);
}
}
return drawable;
}
export function drawElementOnCanvas(
rc: RoughCanvas,
ctx: CanvasRenderingContext2D,
element: DrawElement,
): void {
ctx.save();
ctx.translate(element.x, element.y);
if (element.angle !== 0) {
const cx = element.width / 2;
const cy = element.height / 2;
ctx.translate(cx, cy);
ctx.rotate(element.angle);
ctx.translate(-cx, -cy);
}
ctx.globalAlpha = element.opacity / 100;
if (element.type === 'freedraw') {
drawFreeDraw(ctx, element as DrawFreeDrawElement);
} else if (element.type === 'text') {
// text is rendered separately
} else if (element.type === 'image') {
// image is rendered separately
} else if (element.type === 'arrow' && (element as DrawArrowElement).elbowed) {
drawElbowedArrowPath(ctx, element as DrawArrowElement);
drawArrowheads(ctx, element as DrawArrowElement);
} else {
const drawable = generateRoughDrawable(element);
if (drawable) {
rc.draw(drawable);
}
if (element.type === 'arrow' || element.type === 'line') {
const linear = element as DrawLinearElement;
if (linear.startArrowhead || linear.endArrowhead) {
drawArrowheads(ctx, element as DrawArrowElement);
}
}
}
ctx.restore();
}
export function getFreeDrawOutlinePoints(
element: DrawFreeDrawElement,
): number[][] {
const inputPoints = element.simulatePressure
? element.points
: element.points.length
? element.points.map(([x, y], i) => [x, y, element.pressures[i] ?? 0.5])
: [[0, 0, 0.5]];
return getStroke(inputPoints as number[][], {
simulatePressure: element.simulatePressure,
size: element.strokeWidth * 4.25,
thinning: 0.6,
smoothing: 0.5,
streamline: 0.5,
easing: (t: number) => Math.sin((t * Math.PI) / 2),
last: true,
});
}
function med(a: number[], b: number[]): number[] {
return [(a[0]! + b[0]!) / 2, (a[1]! + b[1]!) / 2];
}
export function getSvgPathFromStroke(points: number[][]): string {
if (!points.length) return '';
const max = points.length - 1;
return points
.reduce(
(acc: (string | number[])[], point, i, arr) => {
if (i === max) {
acc.push(point, med(point, arr[0]!), 'L', arr[0]!, 'Z');
} else {
acc.push(point, med(point, arr[i + 1]!));
}
return acc;
},
['M', points[0]!, 'Q'],
)
.join(' ')
.replace(/(\.\d{2})\d+/g, '$1');
}
function drawFreeDraw(
ctx: CanvasRenderingContext2D,
element: DrawFreeDrawElement,
): void {
if (element.points.length < 2) return;
const outlinePoints = getFreeDrawOutlinePoints(element);
const pathData = getSvgPathFromStroke(outlinePoints);
if (!pathData) return;
const path = new Path2D(pathData);
ctx.fillStyle = element.strokeColor;
ctx.fill(path);
}
function drawArrowheads(
ctx: CanvasRenderingContext2D,
element: DrawArrowElement,
): void {
if (element.points.length < 2) return;
ctx.strokeStyle = element.strokeColor;
ctx.fillStyle = element.strokeColor;
ctx.lineWidth = element.strokeWidth;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
if (element.startArrowhead) {
const p0 = element.points[0]!;
const p1 = element.points[1]!;
const angle = Math.atan2(p0[1] - p1[1], p0[0] - p1[0]);
drawSingleArrowhead(ctx, p0[0], p0[1], angle, element.startArrowhead, element.strokeWidth);
}
if (element.endArrowhead) {
const lastIdx = element.points.length - 1;
const pLast = element.points[lastIdx]!;
const pPrev = element.points[lastIdx - 1]!;
const angle = Math.atan2(pLast[1] - pPrev[1], pLast[0] - pPrev[0]);
drawSingleArrowhead(ctx, pLast[0], pLast[1], angle, element.endArrowhead, element.strokeWidth);
}
}
function drawSingleArrowhead(
ctx: CanvasRenderingContext2D,
tipX: number,
tipY: number,
angle: number,
style: Arrowhead,
strokeWidth: number,
): void {
const arrowLen = strokeWidth * 4 + 8;
const arrowAngle = Math.PI / 6;
const radius = strokeWidth * 2 + 4;
switch (style) {
case 'arrow': {
ctx.beginPath();
ctx.moveTo(
tipX - arrowLen * Math.cos(angle - arrowAngle),
tipY - arrowLen * Math.sin(angle - arrowAngle),
);
ctx.lineTo(tipX, tipY);
ctx.lineTo(
tipX - arrowLen * Math.cos(angle + arrowAngle),
tipY - arrowLen * Math.sin(angle + arrowAngle),
);
ctx.stroke();
break;
}
case 'triangle': {
ctx.beginPath();
ctx.moveTo(tipX, tipY);
ctx.lineTo(
tipX - arrowLen * Math.cos(angle - arrowAngle),
tipY - arrowLen * Math.sin(angle - arrowAngle),
);
ctx.lineTo(
tipX - arrowLen * Math.cos(angle + arrowAngle),
tipY - arrowLen * Math.sin(angle + arrowAngle),
);
ctx.closePath();
ctx.fill();
ctx.stroke();
break;
}
case 'triangle_outline': {
ctx.beginPath();
ctx.moveTo(tipX, tipY);
ctx.lineTo(
tipX - arrowLen * Math.cos(angle - arrowAngle),
tipY - arrowLen * Math.sin(angle - arrowAngle),
);
ctx.lineTo(
tipX - arrowLen * Math.cos(angle + arrowAngle),
tipY - arrowLen * Math.sin(angle + arrowAngle),
);
ctx.closePath();
ctx.stroke();
break;
}
case 'bar': {
const perpAngle = angle + Math.PI / 2;
ctx.beginPath();
ctx.moveTo(
tipX + radius * Math.cos(perpAngle),
tipY + radius * Math.sin(perpAngle),
);
ctx.lineTo(
tipX - radius * Math.cos(perpAngle),
tipY - radius * Math.sin(perpAngle),
);
ctx.stroke();
break;
}
case 'circle': {
ctx.beginPath();
ctx.arc(
tipX - radius * Math.cos(angle),
tipY - radius * Math.sin(angle),
radius,
0,
Math.PI * 2,
);
ctx.fill();
ctx.stroke();
break;
}
case 'circle_outline': {
ctx.beginPath();
ctx.arc(
tipX - radius * Math.cos(angle),
tipY - radius * Math.sin(angle),
radius,
0,
Math.PI * 2,
);
ctx.stroke();
break;
}
case 'diamond': {
const dLen = radius * 1.4;
const cx = tipX - dLen * Math.cos(angle);
const cy = tipY - dLen * Math.sin(angle);
ctx.beginPath();
ctx.moveTo(tipX, tipY);
ctx.lineTo(
cx + radius * Math.cos(angle + Math.PI / 2),
cy + radius * Math.sin(angle + Math.PI / 2),
);
ctx.lineTo(
cx - dLen * Math.cos(angle) + tipX - cx,
cy - dLen * Math.sin(angle) + tipY - cy,
);
ctx.lineTo(
cx - radius * Math.cos(angle + Math.PI / 2),
cy - radius * Math.sin(angle + Math.PI / 2),
);
ctx.closePath();
ctx.fill();
ctx.stroke();
break;
}
case 'diamond_outline': {
const dLen2 = radius * 1.4;
const cx2 = tipX - dLen2 * Math.cos(angle);
const cy2 = tipY - dLen2 * Math.sin(angle);
ctx.beginPath();
ctx.moveTo(tipX, tipY);
ctx.lineTo(
cx2 + radius * Math.cos(angle + Math.PI / 2),
cy2 + radius * Math.sin(angle + Math.PI / 2),
);
ctx.lineTo(
cx2 - dLen2 * Math.cos(angle) + tipX - cx2,
cy2 - dLen2 * Math.sin(angle) + tipY - cy2,
);
ctx.lineTo(
cx2 - radius * Math.cos(angle + Math.PI / 2),
cy2 - radius * Math.sin(angle + Math.PI / 2),
);
ctx.closePath();
ctx.stroke();
break;
}
}
}
/**
* Draw an elbowed (orthogonal) arrow path as straight line segments.
*/
function drawElbowedArrowPath(
ctx: CanvasRenderingContext2D,
element: DrawArrowElement,
): void {
if (element.points.length < 2) return;
ctx.strokeStyle = element.strokeColor;
ctx.lineWidth = element.strokeWidth;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
const dash = getLineDash(element.strokeStyle);
if (dash) ctx.setLineDash(dash);
ctx.beginPath();
ctx.moveTo(element.points[0]![0], element.points[0]![1]);
for (let i = 1; i < element.points.length; i++) {
ctx.lineTo(element.points[i]![0], element.points[i]![1]);
}
ctx.stroke();
if (dash) ctx.setLineDash([]);
}
export function clearShapeCache(): void {
cache.clear();
}
@@ -1,218 +0,0 @@
import type {
DrawElement,
DrawLinearElement,
TransformHandleType,
LocalPoint,
} from '../types';
import { TRANSFORM_HANDLE_SIZE, ROTATION_HANDLE_OFFSET } from '../constants';
import { pointRotate } from '../math/point';
import type { Point } from '../math/types';
import { getLinearElementLocalBounds } from './bounds';
export interface TransformHandle {
type: TransformHandleType;
x: number;
y: number;
width: number;
height: number;
}
export interface LinearPointHandle {
index: number;
x: number;
y: number;
size: number;
}
function isLinearElement(el: DrawElement): el is DrawLinearElement {
return el.type === 'line' || el.type === 'arrow';
}
function rotateLinearPoint(
element: DrawLinearElement,
px: number,
py: number,
): [number, number] {
if (element.angle === 0) return [px, py];
const cx = element.x + element.width / 2;
const cy = element.y + element.height / 2;
return pointRotate([px, py], element.angle, [cx, cy]);
}
export function getLinearPointHandles(
element: DrawElement,
zoom: number,
): LinearPointHandle[] {
if (!isLinearElement(element)) return [];
const size = TRANSFORM_HANDLE_SIZE / zoom;
return element.points.map((pt: LocalPoint, index: number) => {
const [rx, ry] = rotateLinearPoint(element, element.x + pt[0], element.y + pt[1]);
return { index, x: rx, y: ry, size };
});
}
export function getLinearMidpointHandles(
element: DrawElement,
zoom: number,
): LinearPointHandle[] {
if (!isLinearElement(element)) return [];
if (element.points.length < 2) return [];
const size = (TRANSFORM_HANDLE_SIZE * 0.7) / zoom;
const midpoints: LinearPointHandle[] = [];
for (let i = 0; i < element.points.length - 1; i++) {
const p1 = element.points[i]!;
const p2 = element.points[i + 1]!;
const mx = element.x + (p1[0] + p2[0]) / 2;
const my = element.y + (p1[1] + p2[1]) / 2;
const [rx, ry] = rotateLinearPoint(element, mx, my);
midpoints.push({ index: i, x: rx, y: ry, size });
}
return midpoints;
}
export function getLinearPointIndexAtPosition(
element: DrawElement,
sceneX: number,
sceneY: number,
zoom: number,
): number | null {
const handles = getLinearPointHandles(element, zoom);
const threshold = (TRANSFORM_HANDLE_SIZE / zoom) * 1.5;
for (const h of handles) {
if (Math.hypot(sceneX - h.x, sceneY - h.y) <= threshold) {
return h.index;
}
}
return null;
}
export function getLinearMidpointIndexAtPosition(
element: DrawElement,
sceneX: number,
sceneY: number,
zoom: number,
): number | null {
const handles = getLinearMidpointHandles(element, zoom);
const threshold = (TRANSFORM_HANDLE_SIZE / zoom) * 1.5;
for (const h of handles) {
if (Math.hypot(sceneX - h.x, sceneY - h.y) <= threshold) {
return h.index;
}
}
return null;
}
export function getTransformHandles(
element: DrawElement,
zoom: number,
omitSides: Partial<Record<TransformHandleType, boolean>> = DEFAULT_OMIT_SIDES,
): TransformHandle[] {
const isLinear = isLinearElement(element);
if (isLinear) {
const linear = element as DrawLinearElement;
if (!linear.points || linear.points.length <= 2) {
return [];
}
}
const size = TRANSFORM_HANDLE_SIZE / zoom;
const halfSize = size / 2;
const pad = SELECTION_PADDING / zoom;
let x1: number, y1: number, x2: number, y2: number, cx: number, cy: number;
let effectiveAngle: number;
if (isLinear) {
const [bx1, by1, bx2, by2] = getLinearElementLocalBounds(element as DrawLinearElement);
x1 = bx1 - pad;
y1 = by1 - pad;
x2 = bx2 + pad;
y2 = by2 + pad;
cx = element.x + element.width / 2;
cy = element.y + element.height / 2;
effectiveAngle = element.angle;
} else {
const { x, y, width, height } = element;
x1 = x - pad;
y1 = y - pad;
x2 = x + width + pad;
y2 = y + height + pad;
cx = x + width / 2;
cy = y + height / 2;
effectiveAngle = element.angle;
}
const allHandles: TransformHandle[] = [];
if (!omitSides.nw) allHandles.push({ type: 'nw', x: x1 - halfSize, y: y1 - halfSize, width: size, height: size });
if (!omitSides.ne) allHandles.push({ type: 'ne', x: x2 - halfSize, y: y1 - halfSize, width: size, height: size });
if (!omitSides.sw) allHandles.push({ type: 'sw', x: x1 - halfSize, y: y2 - halfSize, width: size, height: size });
if (!omitSides.se) allHandles.push({ type: 'se', x: x2 - halfSize, y: y2 - halfSize, width: size, height: size });
if (!omitSides.n) allHandles.push({ type: 'n', x: cx - halfSize, y: y1 - halfSize, width: size, height: size });
if (!omitSides.s) allHandles.push({ type: 's', x: cx - halfSize, y: y2 - halfSize, width: size, height: size });
if (!omitSides.w) allHandles.push({ type: 'w', x: x1 - halfSize, y: cy - halfSize, width: size, height: size });
if (!omitSides.e) allHandles.push({ type: 'e', x: x2 - halfSize, y: cy - halfSize, width: size, height: size });
if (!omitSides.rotation) {
const rotOffset = ROTATION_HANDLE_OFFSET / zoom;
allHandles.push({
type: 'rotation',
x: cx - halfSize,
y: y1 - rotOffset - halfSize,
width: size,
height: size,
});
}
if (effectiveAngle !== 0) {
const center: Point = [cx, cy];
return allHandles.map((h) => {
const hCenter: Point = [h.x + h.width / 2, h.y + h.height / 2];
const rotated = pointRotate(hCenter, effectiveAngle, center);
return {
...h,
x: rotated[0] - h.width / 2,
y: rotated[1] - h.height / 2,
};
});
}
return allHandles;
}
export const DEFAULT_OMIT_SIDES: Partial<Record<TransformHandleType, boolean>> = {
n: true,
s: true,
e: true,
w: true,
};
export const SELECTION_PADDING = 4;
export function getTransformHandleAtPoint(
element: DrawElement,
point: [number, number],
zoom: number,
): TransformHandleType | null {
const handles = getTransformHandles(element, zoom);
const threshold = (TRANSFORM_HANDLE_SIZE / zoom) * 1.5;
for (const handle of handles) {
const hx = handle.x + handle.width / 2;
const hy = handle.y + handle.height / 2;
const dx = point[0] - hx;
const dy = point[1] - hy;
if (Math.hypot(dx, dy) <= threshold) {
return handle.type;
}
}
return null;
}
@@ -1,121 +0,0 @@
import type { Scene } from '../core/scene';
import type { DrawElement } from '../types';
/**
* Collect all indices that must move together with the selected elements:
* same group members and bound text elements.
*/
function getIndicesToMove(
elements: readonly DrawElement[],
selectedIds: Set<string>,
): Set<number> {
const indices = new Set<number>();
const idsToInclude = new Set<string>(selectedIds);
for (const id of selectedIds) {
const el = elements.find((e) => e.id === id);
if (!el) continue;
for (const gid of el.groupIds) {
for (let i = 0; i < elements.length; i++) {
if (elements[i]!.groupIds.includes(gid)) {
idsToInclude.add(elements[i]!.id);
}
}
}
if (el.boundElements) {
for (const bound of el.boundElements) {
idsToInclude.add(bound.id);
}
}
}
for (let i = 0; i < elements.length; i++) {
if (idsToInclude.has(elements[i]!.id)) {
indices.add(i);
}
}
return indices;
}
export function moveOneRight(
elements: readonly DrawElement[],
selectedIds: Record<string, true>,
scene: Scene,
): void {
const idSet = new Set(Object.keys(selectedIds));
const arr = [...elements];
const toMove = getIndicesToMove(arr, idSet);
const sortedIndices = [...toMove].sort((a, b) => b - a);
for (const idx of sortedIndices) {
if (idx >= arr.length - 1) continue;
const nextIdx = idx + 1;
if (toMove.has(nextIdx)) continue;
[arr[idx], arr[nextIdx]] = [arr[nextIdx]!, arr[idx]!];
}
scene.replaceAllElements(arr);
}
export function moveOneLeft(
elements: readonly DrawElement[],
selectedIds: Record<string, true>,
scene: Scene,
): void {
const idSet = new Set(Object.keys(selectedIds));
const arr = [...elements];
const toMove = getIndicesToMove(arr, idSet);
const sortedIndices = [...toMove].sort((a, b) => a - b);
for (const idx of sortedIndices) {
if (idx <= 0) continue;
const prevIdx = idx - 1;
if (toMove.has(prevIdx)) continue;
[arr[idx], arr[prevIdx]] = [arr[prevIdx]!, arr[idx]!];
}
scene.replaceAllElements(arr);
}
export function moveAllRight(
elements: readonly DrawElement[],
selectedIds: Record<string, true>,
scene: Scene,
): void {
const idSet = new Set(Object.keys(selectedIds));
const toMove = getIndicesToMove(elements, idSet);
const moving: DrawElement[] = [];
const staying: DrawElement[] = [];
for (let i = 0; i < elements.length; i++) {
if (toMove.has(i)) {
moving.push(elements[i]!);
} else {
staying.push(elements[i]!);
}
}
scene.replaceAllElements([...staying, ...moving]);
}
export function moveAllLeft(
elements: readonly DrawElement[],
selectedIds: Record<string, true>,
scene: Scene,
): void {
const idSet = new Set(Object.keys(selectedIds));
const toMove = getIndicesToMove(elements, idSet);
const moving: DrawElement[] = [];
const staying: DrawElement[] = [];
for (let i = 0; i < elements.length; i++) {
if (toMove.has(i)) {
moving.push(elements[i]!);
} else {
staying.push(elements[i]!);
}
}
scene.replaceAllElements([...moving, ...staying]);
}
@@ -1,4 +0,0 @@
declare module '*.woff2' {
const src: string;
export default src;
}
@@ -1,98 +0,0 @@
/**
* Font loading for zq-draw.
*
* Registers hand-drawn (Excalifont), normal (Nunito) and code (Comic Shanns)
* font faces via the FontFace API so they are available for both the canvas
* renderer and the inline text-editor overlay.
*
* Xiaolai is loaded via CSS @font-face from public/fonts/Xiaolai/ with
* unicode-range subsetting so the browser only downloads CJK chunks on demand.
*
* Virgil is kept as a backwards-compatible alias that maps to Excalifont.
*/
import './xiaolai.css';
import ExcalifontLatin from './Excalifont/Excalifont-Regular-a88b72a24fb54c9f94e3b5fdaa7481c9.woff2';
import ExcalifontLatinExt from './Excalifont/Excalifont-Regular-be310b9bcd4f1a43f571c46df7809174.woff2';
import ExcalifontCyrillic from './Excalifont/Excalifont-Regular-b9dcf9d2e50a1eaf42fc664b50a3fd0d.woff2';
import ExcalifontGreek from './Excalifont/Excalifont-Regular-41b173a47b57366892116a575a43e2b6.woff2';
import ExcalifontMisc1 from './Excalifont/Excalifont-Regular-3f2c5db56cc93c5a6873b1361d730c16.woff2';
import ExcalifontMisc2 from './Excalifont/Excalifont-Regular-349fac6ca4700ffec595a7150a0d1e1d.woff2';
import ExcalifontDiacritics from './Excalifont/Excalifont-Regular-623ccf21b21ef6b3a0d87738f77eb071.woff2';
import VirgilRegular from './Virgil/Virgil-Regular.woff2';
import NunitoLatin from './Nunito/Nunito-Regular-XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTQ3j6zbXWjgeg.woff2';
import NunitoLatinExt from './Nunito/Nunito-Regular-XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTo3j6zbXWjgevT5.woff2';
import NunitoCyrillic from './Nunito/Nunito-Regular-XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTA3j6zbXWjgevT5.woff2';
import NunitoCyrillicExt from './Nunito/Nunito-Regular-XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTk3j6zbXWjgevT5.woff2';
import NunitoVietnamese from './Nunito/Nunito-Regular-XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTs3j6zbXWjgevT5.woff2';
import ComicShannsLatin from './ComicShanns/ComicShanns-Regular-279a7b317d12eb88de06167bd672b4b4.woff2';
import ComicShannsLatinExt from './ComicShanns/ComicShanns-Regular-fcb0fc02dcbee4c9846b3e2508668039.woff2';
import ComicShannsMisc1 from './ComicShanns/ComicShanns-Regular-dc6a8806fa96795d7b3be5026f989a17.woff2';
import ComicShannsMisc2 from './ComicShanns/ComicShanns-Regular-6e066e8de2ac57ea9283adb9c24d7f0c.woff2';
interface FontDef {
family: string;
uri: string;
descriptors?: FontFaceDescriptors;
}
const FONT_DEFS: FontDef[] = [
// Excalifont (hand-drawn, default)
{ family: 'Excalifont', uri: ExcalifontLatin, descriptors: { unicodeRange: 'U+20-7e,U+a0-a3,U+a5-a6,U+a8-ab,U+ad-b1,U+b4,U+b6-b8,U+ba-ff,U+131,U+152-153,U+2bc,U+2c6,U+2da,U+2dc,U+304,U+308,U+2013-2014,U+2018-201a,U+201c-201e,U+2020,U+2022,U+2024-2026,U+2030,U+2039-203a,U+20ac,U+2122,U+2212' } },
{ family: 'Excalifont', uri: ExcalifontLatinExt, descriptors: { unicodeRange: 'U+100-130,U+132-137,U+139-149,U+14c-151,U+154-17e,U+192,U+1fc-1ff,U+218-21b,U+237,U+1e80-1e85,U+1ef2-1ef3,U+2113' } },
{ family: 'Excalifont', uri: ExcalifontCyrillic, descriptors: { unicodeRange: 'U+400-45f,U+490-491,U+2116' } },
{ family: 'Excalifont', uri: ExcalifontGreek, descriptors: { unicodeRange: 'U+37e,U+384-38a,U+38c,U+38e-393,U+395-3a1,U+3a3-3a8,U+3aa-3cf,U+3d7' } },
{ family: 'Excalifont', uri: ExcalifontMisc1, descriptors: { unicodeRange: 'U+2c7,U+2d8-2d9,U+2db,U+2dd,U+302,U+306-307,U+30a-30c,U+326-328,U+212e,U+2211,U+fb01-fb02' } },
{ family: 'Excalifont', uri: ExcalifontMisc2, descriptors: { unicodeRange: 'U+462-463,U+472-475,U+4d8-4d9,U+4e2-4e3,U+4e6-4e9,U+4ee-4ef' } },
{ family: 'Excalifont', uri: ExcalifontDiacritics, descriptors: { unicodeRange: 'U+300-301,U+303' } },
// Virgil (legacy hand-drawn, backwards compat)
{ family: 'Virgil', uri: VirgilRegular },
// Nunito (normal / sans-serif)
{ family: 'Nunito', uri: NunitoLatin, descriptors: { weight: '500' } },
{ family: 'Nunito', uri: NunitoLatinExt, descriptors: { weight: '500' } },
{ family: 'Nunito', uri: NunitoCyrillic, descriptors: { weight: '500' } },
{ family: 'Nunito', uri: NunitoCyrillicExt, descriptors: { weight: '500' } },
{ family: 'Nunito', uri: NunitoVietnamese, descriptors: { weight: '500' } },
// Comic Shanns (code / monospace)
{ family: 'Comic Shanns', uri: ComicShannsLatin, descriptors: { unicodeRange: 'U+20-7e,U+a1-a6,U+a8,U+ab-ac,U+af-b1,U+b4,U+b8,U+bb-bc,U+bf-cf,U+d1-d7,U+d9-de,U+e0-ef,U+f1-f7,U+f9-ff,U+131,U+152-153,U+2c6,U+2da,U+2dc,U+2013-2014,U+2018-201a,U+201c-201d,U+2020-2022,U+2026,U+2039-203a,U+2044,U+20ac,U+2191,U+2193,U+2212' } },
{ family: 'Comic Shanns', uri: ComicShannsLatinExt, descriptors: { unicodeRange: 'U+100-10f,U+112-125,U+128-130,U+134-137,U+139-13c,U+141-148,U+14c-151,U+154-161,U+164-165,U+168-17f,U+1bf,U+1f7,U+218-21b,U+237,U+1e80-1e85,U+1ef2-1ef3,U+a75b' } },
{ family: 'Comic Shanns', uri: ComicShannsMisc1, descriptors: { unicodeRange: 'U+2c7,U+2d8-2d9,U+2db,U+2dd,U+315,U+2190,U+2192,U+2200,U+2203-2204,U+2264-2265,U+f6c3' } },
{ family: 'Comic Shanns', uri: ComicShannsMisc2, descriptors: { unicodeRange: 'U+3bb' } },
];
let _loaded = false;
export async function loadDrawFonts(): Promise<void> {
if (_loaded) return;
_loaded = true;
const promises: Promise<FontFace>[] = [];
for (const def of FONT_DEFS) {
try {
const face = new FontFace(
def.family,
`url(${def.uri}) format('woff2')`,
{
display: 'swap',
style: 'normal',
weight: '400',
...def.descriptors,
},
);
document.fonts.add(face);
promises.push(face.load());
} catch {
// skip individual font face errors
}
}
await Promise.allSettled(promises);
}

Some files were not shown because too many files have changed in this diff Show More