draw-table-vue 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.vscode/extensions.json +3 -0
- package/README.md +86 -0
- package/docs/DESIGN.md +36 -0
- package/index.html +13 -0
- package/package.json +24 -0
- package/public/vite.svg +1 -0
- package/src/App.vue +127 -0
- package/src/assets/vue.svg +1 -0
- package/src/components/HelloWorld.vue +41 -0
- package/src/main.ts +5 -0
- package/src/style.css +79 -0
- package/src/table/components/CanvasTable.vue +659 -0
- package/src/table/core/renderer.ts +948 -0
- package/src/table/types/index.ts +77 -0
- package/tsconfig.app.json +16 -0
- package/tsconfig.json +7 -0
- package/tsconfig.node.json +26 -0
- package/vite.config.ts +7 -0
package/README.md
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# Canvas Table Component
|
|
2
|
+
|
|
3
|
+
高性能 Vue 3 + TypeScript Canvas 表格组件,支持大数据量展示与深度定制交互。
|
|
4
|
+
|
|
5
|
+
## 🚀 特性
|
|
6
|
+
|
|
7
|
+
- **Canvas 渲染**:高性能大数据虚拟滚动。
|
|
8
|
+
- **多种单元格**:内置图片、开关、标签、单选/多选框、颜色选择器等。
|
|
9
|
+
- **行内编辑**:支持点击编辑图标或双击唤起自定义弹窗。
|
|
10
|
+
- **高级布局**:支持左侧固定列、单元格合并 (`spanMethod`)。
|
|
11
|
+
- **交互功能**:第一列点击展开详情、单选/多选行。
|
|
12
|
+
- **底部汇总**:每列支持多个聚合函数计算汇总行。
|
|
13
|
+
|
|
14
|
+
## 📦 安装
|
|
15
|
+
|
|
16
|
+
目前作为源码集成在项目中,主要文件位于 `src/table` 目录下。
|
|
17
|
+
|
|
18
|
+
## 🛠 使用方式
|
|
19
|
+
|
|
20
|
+
### 1. 基础用法
|
|
21
|
+
|
|
22
|
+
```vue
|
|
23
|
+
<script setup lang="ts">
|
|
24
|
+
import CanvasTable from './table/components/CanvasTable.vue';
|
|
25
|
+
import { ref, h } from 'vue';
|
|
26
|
+
|
|
27
|
+
const columns = [
|
|
28
|
+
{ key: 'id', title: 'ID', type: 'text', width: 60, fixed: 'left' },
|
|
29
|
+
{
|
|
30
|
+
key: 'name',
|
|
31
|
+
title: 'Name',
|
|
32
|
+
type: 'text',
|
|
33
|
+
width: 150,
|
|
34
|
+
renderEdit: (data, h) => h('div', '编辑姓名...')
|
|
35
|
+
},
|
|
36
|
+
{ key: 'status', title: 'Active', type: 'switch', width: 100 },
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
const data = ref([
|
|
40
|
+
{ id: 1, name: 'User 1', status: true },
|
|
41
|
+
// ... 更多数据
|
|
42
|
+
]);
|
|
43
|
+
</script>
|
|
44
|
+
|
|
45
|
+
<template>
|
|
46
|
+
<CanvasTable :columns="columns" :data="data" />
|
|
47
|
+
</template>
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### 2. ColumnConfig 配置项
|
|
51
|
+
|
|
52
|
+
| 参数 | 类型 | 说明 |
|
|
53
|
+
| --- | --- | --- |
|
|
54
|
+
| `key` | `string` | 数据字段名 |
|
|
55
|
+
| `title` | `string` | 表头文字 |
|
|
56
|
+
| `type` | `CellType` | 单元格类型 (`text`, `image`, `checkbox`, `radio`, `switch`, `color-picker`, `tags`, `expand`, `selection`) |
|
|
57
|
+
| `width` | `number` | 列宽 |
|
|
58
|
+
| `fixed` | `'left' | boolean` | 是否固定到左侧 |
|
|
59
|
+
| `summary` | `SummaryFunction[]` | 聚合函数数组,支持多行聚合 |
|
|
60
|
+
| `renderEdit` | `(data, h) => VNode` | 自定义编辑弹窗渲染逻辑 |
|
|
61
|
+
|
|
62
|
+
### 3. 特殊列类型
|
|
63
|
+
|
|
64
|
+
- **`selection`**: 勾选列。置于最左侧时支持全选/反选,自动同步行选中状态。
|
|
65
|
+
- **`expand`**: 展开列。专门用于放置展开/折叠按钮。
|
|
66
|
+
|
|
67
|
+
### 3. TableOptions 选项
|
|
68
|
+
|
|
69
|
+
| 参数 | 类型 | 说明 |
|
|
70
|
+
| --- | --- | --- |
|
|
71
|
+
| `border` | `boolean` | 是否显示边框 |
|
|
72
|
+
| `stripe` | `boolean` | 是否显示斑马线 |
|
|
73
|
+
| `multiSelect` | `boolean` | 是否支持多选 |
|
|
74
|
+
| `renderExpand` | `(row, h) => VNode` | 展开行自定义内容渲染 |
|
|
75
|
+
| `spanMethod` | `Function` | 合并行或列的方法 |
|
|
76
|
+
|
|
77
|
+
## 🧩 开发者指南
|
|
78
|
+
|
|
79
|
+
- **自定义单元格绘制**:修改 `src/table/core/renderer.ts` 中的 `drawCell` 方法。
|
|
80
|
+
- **样式定制**:编辑 `src/table/components/CanvasTable.vue` 中的 CSS 变量。
|
|
81
|
+
- **交互逻辑**:调整 `src/table/components/CanvasTable.vue` 中的事件监听器。
|
|
82
|
+
|
|
83
|
+
## ⚠️ 注意事项
|
|
84
|
+
|
|
85
|
+
- **Node 版本**:建议使用 Node 16+ 以获得最佳工具链支持。
|
|
86
|
+
- **浏览器支持**:需要支持 Canvas 2D 的现代浏览器。
|
package/docs/DESIGN.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Canvas 表格组件设计文档
|
|
2
|
+
|
|
3
|
+
## 1. 核心架构
|
|
4
|
+
|
|
5
|
+
本组件采用 **Canvas 渲染引擎 + Vue 状态管理** 的混合架构,旨在解决大数据量下 DOM 渲染性能瓶颈。
|
|
6
|
+
|
|
7
|
+
### 1.1 CanvasRenderer (渲染引擎)
|
|
8
|
+
- **职责**:负责表格的所有图形绘制,包括单元格、表头、固定列、固定行、聚合行等。
|
|
9
|
+
- **虚拟滚动**:基于 `scrollY` 和 `scrollX` 计算可见区域的行和列。
|
|
10
|
+
- **布局引擎**:维护 `columnPositions` 和 `rowOffsets`,支持动态行高(如展开行)。
|
|
11
|
+
- **坐标映射**:提供 `getCellAt(x, y)` 和 `getHeaderAt(x, y)` 方法,将屏幕坐标映射到数据模型,支持精确识别展开按钮点击。
|
|
12
|
+
- **内存优化**:实现 `imageCache` 机制,通过 `Map` 缓存已加载的图片对象,并在组件卸载时显式销毁资源,防止内存泄漏。
|
|
13
|
+
|
|
14
|
+
### 1.2 CanvasTable.vue (Vue 容器)
|
|
15
|
+
- **职责**:管理 Canvas 生命周期、同步原生滚动条、维护覆盖层(编辑弹窗、按钮、展开内容)。
|
|
16
|
+
- **交互层**:利用 `Teleport` 将弹窗挂载到 `body`,解决层级冲突;通过 `h` 函数代理逻辑,智能处理 VNode 参数,支持高度自定义的编辑与展开内容渲染。
|
|
17
|
+
|
|
18
|
+
## 2. 核心功能实现
|
|
19
|
+
|
|
20
|
+
### 2.1 多单元格类型支持
|
|
21
|
+
- **内置类型**:`text`, `image`, `checkbox`, `radio`, `switch`, `color-picker`, `tags`。
|
|
22
|
+
- **特殊列**:`selection` (勾选列), `expand` (展开列)。
|
|
23
|
+
- **扩展性**:每种类型在 `renderer.ts` 中有独立的绘制方法,支持通过配置 `ColumnConfig.type` 切换。
|
|
24
|
+
|
|
25
|
+
### 2.2 行内编辑与展开
|
|
26
|
+
- **编辑**:悬浮显示编辑图标或双击主行区域,弹出基于 `h` 函数渲染的自定义对话框。
|
|
27
|
+
- **展开**:使用专门的 `expand` 列展示展开按钮。展开时,主数据行保持固定高度显示在顶部,详细内容在下方撑开显示。
|
|
28
|
+
|
|
29
|
+
### 2.3 布局与样式
|
|
30
|
+
- **固定行列**:支持左侧固定列(通过 `fixed: 'left'` 配置)和顶部/底部固定行。
|
|
31
|
+
- **合并单元格**:支持 `spanMethod` 动态合并行列。
|
|
32
|
+
- **样式**:内置边框、斑马线、聚合行(Summary Rows)支持。
|
|
33
|
+
|
|
34
|
+
## 3. 性能表现
|
|
35
|
+
- **按需绘制**:仅绘制可见区域,1000+ 行数据滚动依然保持 60FPS。
|
|
36
|
+
- **资源清理**:在 `onUnmounted` 中自动销毁渲染引擎实例,释放内存占用。
|
package/index.html
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
7
|
+
<title>draw-table</title>
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<div id="app"></div>
|
|
11
|
+
<script type="module" src="/src/main.ts"></script>
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|
package/package.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "draw-table-vue",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"dev": "vite",
|
|
7
|
+
"build": "vue-tsc -b && vite build",
|
|
8
|
+
"preview": "vite preview"
|
|
9
|
+
},
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"vue": "^3.5.25"
|
|
12
|
+
},
|
|
13
|
+
"devDependencies": {
|
|
14
|
+
"@types/node": "^24.10.1",
|
|
15
|
+
"@vitejs/plugin-vue": "^6.0.2",
|
|
16
|
+
"@vue/tsconfig": "^0.8.1",
|
|
17
|
+
"typescript": "~5.9.3",
|
|
18
|
+
"vite": "^7.3.1",
|
|
19
|
+
"vue-tsc": "^3.1.5"
|
|
20
|
+
},
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=20.19.0"
|
|
23
|
+
}
|
|
24
|
+
}
|
package/public/vite.svg
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
package/src/App.vue
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref } from 'vue';
|
|
3
|
+
import CanvasTable from './table/components/CanvasTable.vue';
|
|
4
|
+
import type { ColumnConfig, TableRow, TableOptions } from './table/types';
|
|
5
|
+
|
|
6
|
+
const columns = ref<ColumnConfig[]>([
|
|
7
|
+
{ key: 'expand', title: '', type: 'expand', width: 40, align: 'center', fixed: 'left' },
|
|
8
|
+
{ key: 'selection', title: '', type: 'selection', width: 40, align: 'center', fixed: 'left' },
|
|
9
|
+
{
|
|
10
|
+
title: 'Group 1',
|
|
11
|
+
fixed: 'left',
|
|
12
|
+
children: [
|
|
13
|
+
{ key: 'id', title: 'ID', type: 'text', width: 60, align: 'center', fixed: 'left' },
|
|
14
|
+
{
|
|
15
|
+
key: 'name',
|
|
16
|
+
title: 'Name',
|
|
17
|
+
type: 'text',
|
|
18
|
+
width: 150,
|
|
19
|
+
fixed: 'left',
|
|
20
|
+
renderHeader: (col, h) => h('div', { style: { color: 'red' } }, col.title),
|
|
21
|
+
renderEdit: (data, h) => h('div', { style: { padding: '20px' } }, [
|
|
22
|
+
h('h3', 'Edit Name'),
|
|
23
|
+
h('input', {
|
|
24
|
+
value: data.name,
|
|
25
|
+
style: { width: '100%', padding: '8px', marginTop: '10px' },
|
|
26
|
+
onInput: (e: any) => data.name = e.target.value
|
|
27
|
+
})
|
|
28
|
+
])
|
|
29
|
+
},
|
|
30
|
+
]
|
|
31
|
+
},
|
|
32
|
+
{ key: 'avatar', title: 'Avatar', type: 'image', width: 80, align: 'center' },
|
|
33
|
+
{ key: 'age', title: 'Age', type: 'text', width: 80, align: 'center', summary: [
|
|
34
|
+
(data) => {
|
|
35
|
+
const total = data.reduce((acc, r) => acc + (Number(r.age) || 0), 0);
|
|
36
|
+
return { label: '平均值', value: Math.round(total / data.length) };
|
|
37
|
+
},
|
|
38
|
+
(data) => {
|
|
39
|
+
const maxAge = data.reduce((acc, r) => Math.max(acc, Number(r.age) || 0), 0);
|
|
40
|
+
return { label: '最大值', value: maxAge };
|
|
41
|
+
}
|
|
42
|
+
]},
|
|
43
|
+
{ key: 'status', title: 'Active', type: 'switch', width: 100, align: 'center', summary: [
|
|
44
|
+
(data) => {
|
|
45
|
+
const activeCount = data.filter(r => r.status).length;
|
|
46
|
+
return { label: '平均值', value: `${activeCount} active` };
|
|
47
|
+
}
|
|
48
|
+
]},
|
|
49
|
+
{ key: 'tags', title: 'Tags', type: 'tags', width: 200 },
|
|
50
|
+
{ key: 'color', title: 'Color', type: 'color-picker', width: 100, align: 'center' },
|
|
51
|
+
{ key: 'address', title: 'Address', type: 'text', width: 300 },
|
|
52
|
+
{ key: 'email', title: 'Email', type: 'text', width: 200 },
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
const generateData = (count: number): TableRow[] => {
|
|
56
|
+
return Array.from({ length: count }, (_, i) => ({
|
|
57
|
+
id: i + 1,
|
|
58
|
+
name: `User ${i + 1}`,
|
|
59
|
+
avatar: `https://api.dicebear.com/7.x/avataaars/svg?seed=${i}`,
|
|
60
|
+
age: 20 + (i % 30),
|
|
61
|
+
status: i % 2 === 0,
|
|
62
|
+
tags: ['Vue', 'TS', 'Canvas'].slice(0, (i % 3) + 1),
|
|
63
|
+
color: i % 2 === 0 ? '#409eff' : '#67c23a',
|
|
64
|
+
address: `Street ${i + 1}, City ${i % 10}`,
|
|
65
|
+
email: `user${i+1}@example.com`
|
|
66
|
+
}));
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const data = ref(generateData(1000));
|
|
70
|
+
|
|
71
|
+
const options: Partial<TableOptions> = {
|
|
72
|
+
border: true,
|
|
73
|
+
stripe: true,
|
|
74
|
+
multiSelect: true,
|
|
75
|
+
renderExpand: (row, h) => h('div', { style: { padding: '20px', background: '#fafafa' } }, [
|
|
76
|
+
h('h4', {}, `Details for ${row.name}`),
|
|
77
|
+
h('p', {}, `This is an expanded row for user ${row.id}. You can put any component here.`),
|
|
78
|
+
h('div', { style: { display: 'flex', gap: '10px' } }, [
|
|
79
|
+
h('button', { onClick: () => alert('Action 1') }, 'Action 1'),
|
|
80
|
+
h('button', { onClick: () => alert('Action 2') }, 'Action 2')
|
|
81
|
+
])
|
|
82
|
+
]),
|
|
83
|
+
spanMethod: ({ rowIndex, columnIndex }) => {
|
|
84
|
+
if (rowIndex === 2 && columnIndex === 7) {
|
|
85
|
+
return { rowspan: 2, colspan: 2 };
|
|
86
|
+
}
|
|
87
|
+
if ((rowIndex === 2 || rowIndex === 3) && (columnIndex === 7 || columnIndex === 8)) {
|
|
88
|
+
if (rowIndex === 2 && columnIndex === 7) return { rowspan: 2, colspan: 2 };
|
|
89
|
+
return { rowspan: 0, colspan: 0 };
|
|
90
|
+
}
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
</script>
|
|
95
|
+
|
|
96
|
+
<template>
|
|
97
|
+
<div class="app-container">
|
|
98
|
+
<h1>Canvas Table Demo (1000 Rows)</h1>
|
|
99
|
+
<div class="table-holder">
|
|
100
|
+
<CanvasTable :columns="columns" v-model:data="data" :options="options" />
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
</template>
|
|
104
|
+
|
|
105
|
+
<style>
|
|
106
|
+
body {
|
|
107
|
+
margin: 0;
|
|
108
|
+
padding: 0;
|
|
109
|
+
height: 100vh;
|
|
110
|
+
overflow: hidden;
|
|
111
|
+
}
|
|
112
|
+
#app {
|
|
113
|
+
width: 100%;
|
|
114
|
+
height: 100%;
|
|
115
|
+
}
|
|
116
|
+
.app-container {
|
|
117
|
+
padding: 20px;
|
|
118
|
+
height: 100%;
|
|
119
|
+
display: flex;
|
|
120
|
+
flex-direction: column;
|
|
121
|
+
}
|
|
122
|
+
.table-holder {
|
|
123
|
+
flex: 1;
|
|
124
|
+
min-height: 0;
|
|
125
|
+
margin-top: 20px;
|
|
126
|
+
}
|
|
127
|
+
</style>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref } from 'vue'
|
|
3
|
+
|
|
4
|
+
defineProps<{ msg: string }>()
|
|
5
|
+
|
|
6
|
+
const count = ref(0)
|
|
7
|
+
</script>
|
|
8
|
+
|
|
9
|
+
<template>
|
|
10
|
+
<h1>{{ msg }}</h1>
|
|
11
|
+
|
|
12
|
+
<div class="card">
|
|
13
|
+
<button type="button" @click="count++">count is {{ count }}</button>
|
|
14
|
+
<p>
|
|
15
|
+
Edit
|
|
16
|
+
<code>components/HelloWorld.vue</code> to test HMR
|
|
17
|
+
</p>
|
|
18
|
+
</div>
|
|
19
|
+
|
|
20
|
+
<p>
|
|
21
|
+
Check out
|
|
22
|
+
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
|
|
23
|
+
>create-vue</a
|
|
24
|
+
>, the official Vue + Vite starter
|
|
25
|
+
</p>
|
|
26
|
+
<p>
|
|
27
|
+
Learn more about IDE Support for Vue in the
|
|
28
|
+
<a
|
|
29
|
+
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
|
|
30
|
+
target="_blank"
|
|
31
|
+
>Vue Docs Scaling up Guide</a
|
|
32
|
+
>.
|
|
33
|
+
</p>
|
|
34
|
+
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
|
|
35
|
+
</template>
|
|
36
|
+
|
|
37
|
+
<style scoped>
|
|
38
|
+
.read-the-docs {
|
|
39
|
+
color: #888;
|
|
40
|
+
}
|
|
41
|
+
</style>
|
package/src/main.ts
ADDED
package/src/style.css
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
|
3
|
+
line-height: 1.5;
|
|
4
|
+
font-weight: 400;
|
|
5
|
+
|
|
6
|
+
color-scheme: light dark;
|
|
7
|
+
color: rgba(255, 255, 255, 0.87);
|
|
8
|
+
background-color: #242424;
|
|
9
|
+
|
|
10
|
+
font-synthesis: none;
|
|
11
|
+
text-rendering: optimizeLegibility;
|
|
12
|
+
-webkit-font-smoothing: antialiased;
|
|
13
|
+
-moz-osx-font-smoothing: grayscale;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
a {
|
|
17
|
+
font-weight: 500;
|
|
18
|
+
color: #646cff;
|
|
19
|
+
text-decoration: inherit;
|
|
20
|
+
}
|
|
21
|
+
a:hover {
|
|
22
|
+
color: #535bf2;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
body {
|
|
26
|
+
margin: 0;
|
|
27
|
+
display: flex;
|
|
28
|
+
place-items: center;
|
|
29
|
+
min-width: 320px;
|
|
30
|
+
min-height: 100vh;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
h1 {
|
|
34
|
+
font-size: 3.2em;
|
|
35
|
+
line-height: 1.1;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
button {
|
|
39
|
+
border-radius: 8px;
|
|
40
|
+
border: 1px solid transparent;
|
|
41
|
+
padding: 0.6em 1.2em;
|
|
42
|
+
font-size: 1em;
|
|
43
|
+
font-weight: 500;
|
|
44
|
+
font-family: inherit;
|
|
45
|
+
background-color: #1a1a1a;
|
|
46
|
+
cursor: pointer;
|
|
47
|
+
transition: border-color 0.25s;
|
|
48
|
+
}
|
|
49
|
+
button:hover {
|
|
50
|
+
border-color: #646cff;
|
|
51
|
+
}
|
|
52
|
+
button:focus,
|
|
53
|
+
button:focus-visible {
|
|
54
|
+
outline: 4px auto -webkit-focus-ring-color;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.card {
|
|
58
|
+
padding: 2em;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
#app {
|
|
62
|
+
max-width: 1280px;
|
|
63
|
+
margin: 0 auto;
|
|
64
|
+
padding: 2rem;
|
|
65
|
+
text-align: center;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
@media (prefers-color-scheme: light) {
|
|
69
|
+
:root {
|
|
70
|
+
color: #213547;
|
|
71
|
+
background-color: #ffffff;
|
|
72
|
+
}
|
|
73
|
+
a:hover {
|
|
74
|
+
color: #747bff;
|
|
75
|
+
}
|
|
76
|
+
button {
|
|
77
|
+
background-color: #f9f9f9;
|
|
78
|
+
}
|
|
79
|
+
}
|