@jjlmoya/utils-tabletop 1.26.0 → 1.28.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/package.json +1 -1
- package/src/category/index.ts +4 -0
- package/src/entries.ts +4 -0
- package/src/index.ts +1 -0
- package/src/tests/locale_completeness.test.ts +1 -1
- package/src/tests/tool_validation.test.ts +1 -1
- package/src/tool/mus-scoreboard/bibliography.astro +16 -0
- package/src/tool/mus-scoreboard/bibliography.ts +12 -0
- package/src/tool/mus-scoreboard/component.astro +169 -0
- package/src/tool/mus-scoreboard/controller.ts +178 -0
- package/src/tool/mus-scoreboard/dom-views.ts +87 -0
- package/src/tool/mus-scoreboard/entry.ts +27 -0
- package/src/tool/mus-scoreboard/evaluator.ts +27 -0
- package/src/tool/mus-scoreboard/i18n/de.ts +187 -0
- package/src/tool/mus-scoreboard/i18n/en.ts +187 -0
- package/src/tool/mus-scoreboard/i18n/es.ts +187 -0
- package/src/tool/mus-scoreboard/i18n/fr.ts +187 -0
- package/src/tool/mus-scoreboard/i18n/id.ts +187 -0
- package/src/tool/mus-scoreboard/i18n/it.ts +187 -0
- package/src/tool/mus-scoreboard/i18n/ja.ts +187 -0
- package/src/tool/mus-scoreboard/i18n/ko.ts +187 -0
- package/src/tool/mus-scoreboard/i18n/nl.ts +187 -0
- package/src/tool/mus-scoreboard/i18n/pl.ts +187 -0
- package/src/tool/mus-scoreboard/i18n/pt.ts +187 -0
- package/src/tool/mus-scoreboard/i18n/ru.ts +187 -0
- package/src/tool/mus-scoreboard/i18n/sv.ts +187 -0
- package/src/tool/mus-scoreboard/i18n/tr.ts +187 -0
- package/src/tool/mus-scoreboard/i18n/zh.ts +187 -0
- package/src/tool/mus-scoreboard/index.ts +11 -0
- package/src/tool/mus-scoreboard/logic.test.ts +82 -0
- package/src/tool/mus-scoreboard/logic.ts +122 -0
- package/src/tool/mus-scoreboard/online-mus-scoreboard.css +740 -0
- package/src/tool/mus-scoreboard/seo.astro +16 -0
- package/src/tool/mus-scoreboard/storage.ts +29 -0
- package/src/tool/mus-scoreboard/ui.ts +63 -0
- package/src/tool/token-stamp-studio/bibliography.astro +16 -0
- package/src/tool/token-stamp-studio/bibliography.ts +12 -0
- package/src/tool/token-stamp-studio/component.astro +91 -0
- package/src/tool/token-stamp-studio/controller.ts +179 -0
- package/src/tool/token-stamp-studio/dom-views.ts +266 -0
- package/src/tool/token-stamp-studio/entry.ts +27 -0
- package/src/tool/token-stamp-studio/evaluator.ts +6 -0
- package/src/tool/token-stamp-studio/i18n/de.ts +132 -0
- package/src/tool/token-stamp-studio/i18n/en.ts +132 -0
- package/src/tool/token-stamp-studio/i18n/es.ts +132 -0
- package/src/tool/token-stamp-studio/i18n/fr.ts +132 -0
- package/src/tool/token-stamp-studio/i18n/id.ts +132 -0
- package/src/tool/token-stamp-studio/i18n/it.ts +132 -0
- package/src/tool/token-stamp-studio/i18n/ja.ts +132 -0
- package/src/tool/token-stamp-studio/i18n/ko.ts +132 -0
- package/src/tool/token-stamp-studio/i18n/nl.ts +132 -0
- package/src/tool/token-stamp-studio/i18n/pl.ts +132 -0
- package/src/tool/token-stamp-studio/i18n/pt.ts +132 -0
- package/src/tool/token-stamp-studio/i18n/ru.ts +132 -0
- package/src/tool/token-stamp-studio/i18n/sv.ts +132 -0
- package/src/tool/token-stamp-studio/i18n/tr.ts +132 -0
- package/src/tool/token-stamp-studio/i18n/zh.ts +132 -0
- package/src/tool/token-stamp-studio/index.ts +11 -0
- package/src/tool/token-stamp-studio/logic.test.ts +36 -0
- package/src/tool/token-stamp-studio/logic.ts +127 -0
- package/src/tool/token-stamp-studio/seo.astro +16 -0
- package/src/tool/token-stamp-studio/storage.ts +69 -0
- package/src/tool/token-stamp-studio/store.ts +139 -0
- package/src/tool/token-stamp-studio/token-stamp-studio.css +646 -0
- package/src/tool/token-stamp-studio/types.ts +65 -0
- package/src/tool/token-stamp-studio/ui.ts +50 -0
- package/src/tool/token-stamp-studio/workspace-media.ts +36 -0
- package/src/tool/token-stamp-studio/workspace.ts +83 -0
- package/src/tools.ts +4 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type { FAQPage, HowTo, SoftwareApplication, WithContext } from 'schema-dts';
|
|
2
|
+
import { bibliography } from '../bibliography';
|
|
3
|
+
import type { TokenStampLocaleContent, TokenStampUI } from '../entry';
|
|
4
|
+
|
|
5
|
+
const ui: TokenStampUI = {
|
|
6
|
+
frameLegend: '选择边框',
|
|
7
|
+
frameHint: '选择能在看到名字前就传达角色身份的轮廓边框。',
|
|
8
|
+
randomFrame: '随机边框',
|
|
9
|
+
surfaceLegend: '棋子配色设置',
|
|
10
|
+
backgroundLabel: '背景颜色',
|
|
11
|
+
borderLabel: '边框颜色',
|
|
12
|
+
textLabel: '文字颜色',
|
|
13
|
+
overlayLabel: '色调覆盖',
|
|
14
|
+
randomColors: '随机颜色',
|
|
15
|
+
borderWidthLabel: '边框粗细',
|
|
16
|
+
opacityLabel: '边框透明度',
|
|
17
|
+
overlayOpacityLabel: '头像遮罩透明度',
|
|
18
|
+
stageLabel: '实时棋子锻造台',
|
|
19
|
+
chooseImage: '选择头像',
|
|
20
|
+
clearImage: '清除头像',
|
|
21
|
+
positionHint: '可以直接在棋子上拖动调整头像或文字标签的位置。',
|
|
22
|
+
markerName: '标记名称',
|
|
23
|
+
markerNamePlaceholder: '为此标记命名',
|
|
24
|
+
textLegend: '添加清晰可读的桌面细节',
|
|
25
|
+
textHint: '使用简短标签(如名字、等级、职业或状态),并将其拖动到棋子上的合适位置。',
|
|
26
|
+
textPlaceholder: '角色名字或职业',
|
|
27
|
+
addText: '添加标签',
|
|
28
|
+
removeText: '移除标签',
|
|
29
|
+
textSizeLabel: '字体大小',
|
|
30
|
+
alignmentLabel: '对齐方式',
|
|
31
|
+
alignLeft: '左对齐',
|
|
32
|
+
alignCenter: '居中对齐',
|
|
33
|
+
alignRight: '右对齐',
|
|
34
|
+
imageLegend: '微调头像',
|
|
35
|
+
imageZoomLabel: '头像缩放',
|
|
36
|
+
scaleLabel: '导出比例',
|
|
37
|
+
savedMarkers: '已保存的标记',
|
|
38
|
+
noSavedMarkers: '已保存的标记将显示在此处,方便重新打开和编辑。',
|
|
39
|
+
reuseMarker: '选择标记以重新在锻造台编辑。',
|
|
40
|
+
deleteMarker: '删除标记',
|
|
41
|
+
newMarker: '新建标记',
|
|
42
|
+
download: '下载 PNG',
|
|
43
|
+
copy: '复制 PNG',
|
|
44
|
+
downloadBatch: '批量下载',
|
|
45
|
+
batchLabel: '高级批量导出',
|
|
46
|
+
batchHint: '可选:使用当前的边框和配色方案批量导出多个头像。',
|
|
47
|
+
chooseBatch: '选择批量图片',
|
|
48
|
+
batchReady: '已准备好 {count} 张图片。',
|
|
49
|
+
noImage: '未加载头像',
|
|
50
|
+
noText: '暂无标签。请添加团队所需的详细信息。',
|
|
51
|
+
tokenDetails: '角色棋子控制面板',
|
|
52
|
+
exportHint: '导出的 PNG 图片在选定轮廓外保持透明,可直接用于跑团软件(VTT)或打印。',
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const faq = [
|
|
56
|
+
{
|
|
57
|
+
question: '什么样的头像适合制作角色棋子?',
|
|
58
|
+
answer: '选择面部清晰、与边框对比鲜明的图片。头部和肩膀的近景裁切在虚拟桌面上缩小显示时最清晰。',
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
question: '如何在边框内精准调整头像位置?',
|
|
62
|
+
answer: '导入图片后直接在棋子上拖动。使用头像缩放功能可以放大面部,而无需更改边框形状。',
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
question: '可以在棋子上添加角色名字和等级吗?',
|
|
66
|
+
answer: '可以。添加简短的文本标签,调整字体大小和对齐方式,然后拖动到棋子上的清晰位置。',
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
question: '导出的 PNG 图片背景是透明的吗?',
|
|
70
|
+
answer: '是的。导出的图片在边框轮廓之外保持透明,方便直接覆盖在战场地图上。',
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
question: '可以一次性为整个小队制作棋子吗?',
|
|
74
|
+
answer: '可以。使用批量导出功能,导入多张图片,即可应用相同的边框样式批量生成透明 PNG 棋子。',
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
question: '保存的标记保存在哪里?',
|
|
78
|
+
answer: '标记保存在您浏览器的本地存储中,不会将您的个人图片上传到任何服务器。',
|
|
79
|
+
},
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
const howTo = [
|
|
83
|
+
{ name: '选择适合角色的边框', text: '根据角色属性选择边框:圆形适合主角,六边形适合战术棋盘,星形适合 Boss。' },
|
|
84
|
+
{ name: '导入并调整头像位置', text: '拖入图片,调整拖动和缩放比例,使面部处于中央。' },
|
|
85
|
+
{ name: '添加必要的桌面文本', text: '添加简短的名字或状态标签,避免遮挡主要画面。' },
|
|
86
|
+
{ name: '导出透明 PNG', text: '下载透明 PNG 棋子,直接在 VTT 跑团软件中使用。' },
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
const faqSchema: WithContext<FAQPage> = {
|
|
90
|
+
'@context': 'https://schema.org',
|
|
91
|
+
'@type': 'FAQPage',
|
|
92
|
+
mainEntity: faq.map((item) => ({ '@type': 'Question', name: item.question, acceptedAnswer: { '@type': 'Answer', text: item.answer } })),
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const appSchema: WithContext<SoftwareApplication> = {
|
|
96
|
+
'@context': 'https://schema.org',
|
|
97
|
+
'@type': 'SoftwareApplication',
|
|
98
|
+
name: 'Token Stamp Studio',
|
|
99
|
+
operatingSystem: 'All',
|
|
100
|
+
applicationCategory: 'DesignApplication',
|
|
101
|
+
description: 'TRPG 桌面角色棋子(Token)制作工具,支持自定义边框、文本标签、背景透明及批量 PNG 导出。',
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const howToSchema: WithContext<HowTo> = {
|
|
105
|
+
'@context': 'https://schema.org',
|
|
106
|
+
'@type': 'HowTo',
|
|
107
|
+
name: '如何制作跑团角色棋子',
|
|
108
|
+
step: howTo.map((item) => ({ '@type': 'HowToStep', name: item.name, text: item.text })),
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
export const content: TokenStampLocaleContent = {
|
|
112
|
+
slug: 'token-stamp-studio',
|
|
113
|
+
title: 'TRPG跑团角色棋子在线制作工具 (Token Stamp)',
|
|
114
|
+
description: 'TRPG 桌面角色棋子(Token)制作工具,支持自定义边框、文本标签、背景透明及批量 PNG 导出。',
|
|
115
|
+
ui,
|
|
116
|
+
seo: [
|
|
117
|
+
{ type: 'title', text: '制作在游戏地图上清晰辨识的在线桌面跑团角色棋子', level: 2 },
|
|
118
|
+
{ type: 'paragraph', html: '棋子是桌游和 TRPG 跑团中不可或缺的核心视觉交流工具。棋子的轮廓能够在玩家放大地图细节之前,就直观地传达出当前代表的是英雄、强敌怪物、魔法效果还是重要盟友。为角色选择最契合的边框,并确保人物头像和文本标签在实际缩放比例下依然清晰易读。' },
|
|
119
|
+
{ type: 'title', text: '为不同定位与阵营的角色选择最合适的边框样式', level: 2 },
|
|
120
|
+
{ type: 'list', items: ['<strong>圆形与圆环:</strong> 玩家角色(PC)与常驻盟友的最经典可靠选择。', '<strong>六边形与八边形:</strong> 适合包含大量战斗单位的战术棋盘与战场地图。', '<strong>星形:</strong> 非常适合 Boss 怪物、精英敌人或需要引起注意的重要目标。', '<strong>云朵形:</strong> 适合召唤兽、精灵、魔宠等特殊的存在。'] },
|
|
121
|
+
{ type: 'tip', title: '针对游戏时实际最小显示尺寸进行细致设计', html: '缩小地图视角,观察棋子缩到很小时面部特征与文字是否依然清晰可辨。' },
|
|
122
|
+
{ type: 'title', text: '使用简短而实用的文本标签辅助对局', level: 2 },
|
|
123
|
+
{ type: 'paragraph', html: '跑团棋子无需替代完整的角色卡,只需添加名字、等级或晕眩等状态标签,避免遮挡插画。' },
|
|
124
|
+
{ type: 'title', text: '一键为整个冒险小队制作视觉统一的角色棋子', level: 2 },
|
|
125
|
+
{ type: 'paragraph', html: '批量导出功能可以在保持团队整体视觉规范统一的同时,完美保留每个角色的独特插画。' },
|
|
126
|
+
{ type: 'tip', title: '设备本地自动保存功能', html: '正在制作的标记会自动保存在浏览器本地,随角色升级轻松打开重新编辑。' },
|
|
127
|
+
],
|
|
128
|
+
faq,
|
|
129
|
+
bibliography,
|
|
130
|
+
howTo,
|
|
131
|
+
schemas: [faqSchema, appSchema, howToSchema] as unknown as Record<string, unknown>[],
|
|
132
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ToolDefinition } from '../../types';
|
|
2
|
+
import { tokenStampStudio } from './entry';
|
|
3
|
+
|
|
4
|
+
export * from './entry';
|
|
5
|
+
|
|
6
|
+
export const TOKEN_STAMP_STUDIO_TOOL: ToolDefinition = {
|
|
7
|
+
entry: tokenStampStudio,
|
|
8
|
+
Component: () => import('./component.astro'),
|
|
9
|
+
SEOComponent: () => import('./seo.astro'),
|
|
10
|
+
BibliographyComponent: () => import('./bibliography.astro'),
|
|
11
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { FRAME_PRESETS, clamp, createDefaultState, getFrameGeometry, getStatus, hitTestText } from './logic';
|
|
3
|
+
|
|
4
|
+
describe('token stamp studio logic', () => {
|
|
5
|
+
it('starts with a usable foundry state', () => {
|
|
6
|
+
const state = createDefaultState();
|
|
7
|
+
expect(state.frameId).toBe(FRAME_PRESETS[0]!.id);
|
|
8
|
+
expect(state.overlayOpacity).toBe(0);
|
|
9
|
+
expect(state.imageSrc).toBeNull();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('clamps numeric controls to their safe ranges', () => {
|
|
13
|
+
expect(clamp(-4, 0, 1)).toBe(0);
|
|
14
|
+
expect(clamp(2, 0, 1)).toBe(1);
|
|
15
|
+
expect(clamp(0.4, 0, 1)).toBe(0.4);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('builds a geometry for every frame family', () => {
|
|
19
|
+
FRAME_PRESETS.forEach((frame) => expect(getFrameGeometry(frame.shape)).toBeDefined());
|
|
20
|
+
expect(getFrameGeometry('circle').kind).toBe('circle');
|
|
21
|
+
expect(getFrameGeometry('hexagon').points).toHaveLength(6);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('diagnoses the two empty states before ready', () => {
|
|
25
|
+
const messages = { statusReady: 'Ready', statusAddImage: 'Add an image', statusAddText: 'Finish the label' };
|
|
26
|
+
const state = createDefaultState();
|
|
27
|
+
expect(getStatus(state, messages).message).toBe('Add an image');
|
|
28
|
+
expect(getStatus({ ...state, imageSrc: 'data:image/png;base64,a', texts: [{ id: 'a', text: '', x: 0.5, y: 0.8, size: 54, align: 'center' }], selectedTextId: 'a' }, messages).message).toBe('Finish the label');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('detects a text layer under the pointer', () => {
|
|
32
|
+
const layer = { id: 'a', text: 'Goblin', x: 0.5, y: 0.8, size: 54, align: 'center' as const };
|
|
33
|
+
expect(hitTestText(layer, { x: 0.5, y: 0.8 })).toBe(true);
|
|
34
|
+
expect(hitTestText(layer, { x: 0.05, y: 0.1 })).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import type { FrameGeometry, FramePreset, TextLayer, TokenPalette, TokenStampState, TokenStampStatus } from './types';
|
|
2
|
+
|
|
3
|
+
export const FRAME_PRESETS: FramePreset[] = [
|
|
4
|
+
{ id: 'ember-ring', name: 'Ember ring', shortName: 'Ring', description: 'A warm double ring for classic character tokens.', shape: 'circle', accent: '#d89555' },
|
|
5
|
+
{ id: 'parchment-square', name: 'Parchment square', shortName: 'Square', description: 'A clean square cut for maps, cards, and item art.', shape: 'square', accent: '#c6a875' },
|
|
6
|
+
{ id: 'hex-warden', name: 'Hex warden', shortName: 'Hex', description: 'A six sided badge with a tactical silhouette.', shape: 'hexagon', accent: '#89b9ae' },
|
|
7
|
+
{ id: 'octagon-steel', name: 'Octagon steel', shortName: 'Octagon', description: 'A hard edged frame for monsters and machines.', shape: 'octagon', accent: '#a9b6c4' },
|
|
8
|
+
{ id: 'star-forge', name: 'Star forge', shortName: 'Star', description: 'A bright eight point emblem for heroes and relics.', shape: 'star', accent: '#e0b35e' },
|
|
9
|
+
{ id: 'diamond-sigil', name: 'Diamond sigil', shortName: 'Diamond', description: 'A turned frame that reads like a spell seal.', shape: 'diamond', accent: '#b48bd0' },
|
|
10
|
+
{ id: 'ticket-relic', name: 'Ticket relic', shortName: 'Ticket', description: 'A notched frame for quests, clues, and locations.', shape: 'ticket', accent: '#d17f69' },
|
|
11
|
+
{ id: 'cloud-echo', name: 'Cloud echo', shortName: 'Cloud', description: 'A soft irregular edge for spirits and fey creatures.', shape: 'cloud', accent: '#84b9ca' },
|
|
12
|
+
{ id: 'scallop-crown', name: 'Scallop crown', shortName: 'Scallop', description: 'A ceremonial edge for bosses and named allies.', shape: 'scallop', accent: '#d4a4b9' },
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
export const DEFAULT_FRAME_ID = FRAME_PRESETS[0]!.id;
|
|
16
|
+
|
|
17
|
+
export const COLOR_PALETTES: TokenPalette[] = [
|
|
18
|
+
{ background: '#1d2430', border: '#d89555', text: '#fff4d6', overlay: '#e05b4f' },
|
|
19
|
+
{ background: '#182b2a', border: '#80b8a5', text: '#f5f1dc', overlay: '#d26b5b' },
|
|
20
|
+
{ background: '#29233a', border: '#c49ad7', text: '#fff1ca', overlay: '#6e91ca' },
|
|
21
|
+
{ background: '#33251e', border: '#d4a45f', text: '#fff8e9', overlay: '#8f574c' },
|
|
22
|
+
{ background: '#202c3d', border: '#86a9d3', text: '#f8f0dd', overlay: '#9c668c' },
|
|
23
|
+
{ background: '#243329', border: '#c1b36a', text: '#fffbe3', overlay: '#b86a4e' },
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
export function clamp(value: number, min: number, max: number): number {
|
|
27
|
+
return Math.min(max, Math.max(min, value));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function createDefaultState(): TokenStampState {
|
|
31
|
+
return {
|
|
32
|
+
markerName: '',
|
|
33
|
+
frameId: DEFAULT_FRAME_ID,
|
|
34
|
+
background: '#1d2430',
|
|
35
|
+
border: '#d89555',
|
|
36
|
+
borderWidth: 16,
|
|
37
|
+
borderOpacity: 1,
|
|
38
|
+
overlay: '#e05b4f',
|
|
39
|
+
overlayOpacity: 0,
|
|
40
|
+
imageSrc: null,
|
|
41
|
+
imageName: '',
|
|
42
|
+
imageX: 0,
|
|
43
|
+
imageY: 0,
|
|
44
|
+
imageZoom: 1,
|
|
45
|
+
scale: 1,
|
|
46
|
+
textColor: '#fff4d6',
|
|
47
|
+
texts: [],
|
|
48
|
+
selectedTextId: null,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function getFramePreset(frameId: string): FramePreset {
|
|
53
|
+
return FRAME_PRESETS.find((frame) => frame.id === frameId) ?? FRAME_PRESETS[0]!;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function getRandomPalette(): TokenPalette {
|
|
57
|
+
return COLOR_PALETTES[Math.floor(Math.random() * COLOR_PALETTES.length)]!;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function polygonGeometry(sides: number, rotation: number): FrameGeometry {
|
|
61
|
+
const points = Array.from({ length: sides }, (_, index) => {
|
|
62
|
+
const angle = rotation + (Math.PI * 2 * index) / sides;
|
|
63
|
+
return { x: 0.5 + Math.cos(angle) * 0.45, y: 0.5 + Math.sin(angle) * 0.45 };
|
|
64
|
+
});
|
|
65
|
+
return { kind: 'polygon', points };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function starGeometry(): FrameGeometry {
|
|
69
|
+
const points = Array.from({ length: 16 }, (_, index) => {
|
|
70
|
+
const angle = -Math.PI / 2 + (Math.PI * 2 * index) / 16;
|
|
71
|
+
const radius = index % 2 === 0 ? 0.45 : 0.25;
|
|
72
|
+
return { x: 0.5 + Math.cos(angle) * radius, y: 0.5 + Math.sin(angle) * radius };
|
|
73
|
+
});
|
|
74
|
+
return { kind: 'polygon', points };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function ticketGeometry(): FrameGeometry {
|
|
78
|
+
return { kind: 'polygon', points: [
|
|
79
|
+
{ x: 0.24, y: 0.08 }, { x: 0.76, y: 0.08 }, { x: 0.82, y: 0.14 },
|
|
80
|
+
{ x: 0.82, y: 0.86 }, { x: 0.76, y: 0.92 }, { x: 0.24, y: 0.92 },
|
|
81
|
+
{ x: 0.18, y: 0.86 }, { x: 0.18, y: 0.14 },
|
|
82
|
+
] };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function scallopGeometry(): FrameGeometry {
|
|
86
|
+
const points = Array.from({ length: 24 }, (_, index) => {
|
|
87
|
+
const angle = -Math.PI / 2 + (Math.PI * 2 * index) / 24;
|
|
88
|
+
const radius = index % 2 === 0 ? 0.45 : 0.38;
|
|
89
|
+
return { x: 0.5 + Math.cos(angle) * radius, y: 0.5 + Math.sin(angle) * radius };
|
|
90
|
+
});
|
|
91
|
+
return { kind: 'polygon', points };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const FRAME_GEOMETRIES: Record<FramePreset['shape'], () => FrameGeometry> = {
|
|
95
|
+
circle: () => ({ kind: 'circle' }),
|
|
96
|
+
square: () => polygonGeometry(4, Math.PI / 4),
|
|
97
|
+
hexagon: () => polygonGeometry(6, -Math.PI / 2),
|
|
98
|
+
octagon: () => polygonGeometry(8, Math.PI / 8),
|
|
99
|
+
star: starGeometry,
|
|
100
|
+
diamond: () => polygonGeometry(4, 0),
|
|
101
|
+
ticket: ticketGeometry,
|
|
102
|
+
cloud: () => ({ kind: 'circle' }),
|
|
103
|
+
scallop: scallopGeometry,
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
export function getFrameGeometry(shape: FramePreset['shape']): FrameGeometry {
|
|
107
|
+
return FRAME_GEOMETRIES[shape]();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function updateTextLayer(texts: TextLayer[], selectedTextId: string | null, update: Partial<TextLayer>): TextLayer[] {
|
|
111
|
+
return texts.map((text) => text.id === selectedTextId ? { ...text, ...update } : text);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function createTextLayer(index: number): TextLayer {
|
|
115
|
+
return { id: `text-${Date.now()}-${index}`, text: 'New label', x: 0.5, y: 0.8, size: 54, align: 'center' };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function getStatus(state: TokenStampState, ui: { statusReady: string; statusAddImage: string; statusAddText: string }): TokenStampStatus {
|
|
119
|
+
if (!state.imageSrc) return { tone: 'attention', message: ui.statusAddImage };
|
|
120
|
+
if (state.texts.some((text) => text.text.trim().length === 0)) return { tone: 'attention', message: ui.statusAddText };
|
|
121
|
+
return { tone: 'ready', message: ui.statusReady };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function hitTestText(text: TextLayer, point: { x: number; y: number }): boolean {
|
|
125
|
+
const width = Math.max(0.12, text.text.length * text.size / 900);
|
|
126
|
+
return Math.abs(point.x - text.x) <= width / 2 && Math.abs(point.y - text.y) <= text.size / 1100;
|
|
127
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { SEORenderer } from '@jjlmoya/utils-shared';
|
|
3
|
+
import { tokenStampStudio } from './index';
|
|
4
|
+
import type { KnownLocale } from '../../types';
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
locale?: KnownLocale;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const { locale = 'en' } = Astro.props as Props;
|
|
11
|
+
const loader = tokenStampStudio.i18n[locale] || tokenStampStudio.i18n.en;
|
|
12
|
+
const content = await loader?.();
|
|
13
|
+
if (!content) return null;
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
{content.seo?.length > 0 && <SEORenderer content={{ locale, sections: content.seo }} />}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { SavedMarker, TokenStampState } from './types';
|
|
2
|
+
|
|
3
|
+
const STORAGE_KEY = 'jjlmoya-token-stamp-studio';
|
|
4
|
+
const LIBRARY_KEY = 'jjlmoya-token-stamp-library';
|
|
5
|
+
const ACTIVE_MARKER_KEY = 'jjlmoya-token-stamp-active-marker';
|
|
6
|
+
|
|
7
|
+
export function saveTokenState(state: TokenStampState): boolean {
|
|
8
|
+
try {
|
|
9
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
|
10
|
+
return true;
|
|
11
|
+
} catch {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function loadTokenState(): TokenStampState | null {
|
|
17
|
+
try {
|
|
18
|
+
const raw = localStorage.getItem(STORAGE_KEY);
|
|
19
|
+
return raw ? JSON.parse(raw) as TokenStampState : null;
|
|
20
|
+
} catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function clearTokenState(): void {
|
|
26
|
+
try {
|
|
27
|
+
localStorage.removeItem(STORAGE_KEY);
|
|
28
|
+
} catch {}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function listSavedMarkers(): SavedMarker[] {
|
|
32
|
+
try {
|
|
33
|
+
const raw = localStorage.getItem(LIBRARY_KEY);
|
|
34
|
+
const markers = raw ? JSON.parse(raw) as SavedMarker[] : [];
|
|
35
|
+
return Array.isArray(markers) ? markers : [];
|
|
36
|
+
} catch {
|
|
37
|
+
return [];
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function saveMarker(marker: SavedMarker): SavedMarker[] {
|
|
42
|
+
const markers = [marker, ...listSavedMarkers().filter((item) => item.id !== marker.id)].slice(0, 24);
|
|
43
|
+
try {
|
|
44
|
+
localStorage.setItem(LIBRARY_KEY, JSON.stringify(markers));
|
|
45
|
+
} catch {}
|
|
46
|
+
return markers;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function deleteMarker(id: string): SavedMarker[] {
|
|
50
|
+
const markers = listSavedMarkers().filter((marker) => marker.id !== id);
|
|
51
|
+
try {
|
|
52
|
+
localStorage.setItem(LIBRARY_KEY, JSON.stringify(markers));
|
|
53
|
+
} catch {}
|
|
54
|
+
return markers;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function saveActiveMarkerId(id: string): void {
|
|
58
|
+
try {
|
|
59
|
+
localStorage.setItem(ACTIVE_MARKER_KEY, id);
|
|
60
|
+
} catch {}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function loadActiveMarkerId(): string | null {
|
|
64
|
+
try {
|
|
65
|
+
return localStorage.getItem(ACTIVE_MARKER_KEY);
|
|
66
|
+
} catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { createDefaultState, FRAME_PRESETS } from './logic';
|
|
2
|
+
import { drawTokenCanvas, renderFrameGallery, renderMarkerLibrary, renderTextLayers } from './dom-views';
|
|
3
|
+
import { deleteMarker, listSavedMarkers, loadActiveMarkerId, loadTokenState, saveActiveMarkerId, saveMarker, saveTokenState } from './storage';
|
|
4
|
+
import { loadImage } from './workspace-media';
|
|
5
|
+
import type { StudioElements } from './controller';
|
|
6
|
+
import type { TokenStampUI } from './ui';
|
|
7
|
+
import type { SavedMarker, TokenStampState } from './types';
|
|
8
|
+
|
|
9
|
+
function markerName(state: TokenStampState): string {
|
|
10
|
+
return state.markerName?.trim() || state.texts.find((text) => text.text.trim())?.text.trim() || state.imageName.replace(/\.[^/.]+$/, '') || 'Unnamed marker';
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function createMarkerId(): string {
|
|
14
|
+
return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function getActiveMarkerId(markers: SavedMarker[]): string {
|
|
18
|
+
const stored = loadActiveMarkerId();
|
|
19
|
+
if (stored) return stored;
|
|
20
|
+
if (markers[0]) return markers[0].id;
|
|
21
|
+
return createMarkerId();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function normalizeActiveMarkerId(markers: SavedMarker[], id: string): string {
|
|
25
|
+
return markers.some((marker) => marker.id === id) ? id : markers[0]!.id;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function createInitialMarkerSession(): { state: TokenStampState; markers: SavedMarker[]; activeMarkerId: string } {
|
|
29
|
+
const storedState = loadTokenState() || createDefaultState();
|
|
30
|
+
let markers = listSavedMarkers();
|
|
31
|
+
let activeMarkerId = getActiveMarkerId(markers);
|
|
32
|
+
if (markers.length === 0) markers = saveMarker({ id: activeMarkerId, name: markerName(storedState), updatedAt: Date.now(), state: storedState });
|
|
33
|
+
activeMarkerId = normalizeActiveMarkerId(markers, activeMarkerId);
|
|
34
|
+
const active = markers.find((marker) => marker.id === activeMarkerId);
|
|
35
|
+
return { markers, activeMarkerId, state: active?.state || storedState };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export class StudioStore {
|
|
39
|
+
state: TokenStampState;
|
|
40
|
+
markers: SavedMarker[];
|
|
41
|
+
activeMarkerId: string;
|
|
42
|
+
image: HTMLImageElement | null = null;
|
|
43
|
+
|
|
44
|
+
constructor(private readonly elements: StudioElements, private readonly ui: TokenStampUI) {
|
|
45
|
+
const session = createInitialMarkerSession();
|
|
46
|
+
this.markers = session.markers;
|
|
47
|
+
this.activeMarkerId = session.activeMarkerId;
|
|
48
|
+
this.state = session.state;
|
|
49
|
+
saveActiveMarkerId(this.activeMarkerId);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async restore(): Promise<void> {
|
|
53
|
+
if (!this.state.imageSrc) return;
|
|
54
|
+
try { this.image = await loadImage(this.state.imageSrc); } catch { this.state = { ...this.state, imageSrc: null, imageName: '' }; }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
getState = (): TokenStampState => this.state;
|
|
58
|
+
getMarkers = (): SavedMarker[] => this.markers;
|
|
59
|
+
getImage = (): HTMLImageElement | null => this.image;
|
|
60
|
+
|
|
61
|
+
setState = (next: TokenStampState): void => {
|
|
62
|
+
this.state = next;
|
|
63
|
+
saveTokenState(next);
|
|
64
|
+
const active = this.markers.find((marker) => marker.id === this.activeMarkerId);
|
|
65
|
+
if (active) this.markers = saveMarker({ ...active, name: markerName(next), updatedAt: Date.now(), state: next });
|
|
66
|
+
this.render();
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
setImage = (nextImage: HTMLImageElement | null): void => { this.image = nextImage; this.render(); };
|
|
70
|
+
|
|
71
|
+
selectMarker = (marker: SavedMarker): void => {
|
|
72
|
+
this.activeMarkerId = marker.id;
|
|
73
|
+
this.state = marker.state;
|
|
74
|
+
saveActiveMarkerId(marker.id);
|
|
75
|
+
if (marker.state.imageSrc) void loadImage(marker.state.imageSrc).then(this.setImage).catch(() => this.setImage(null));
|
|
76
|
+
else this.setImage(null);
|
|
77
|
+
this.renderLibrary();
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
addMarker = (): void => {
|
|
81
|
+
const state = createDefaultState();
|
|
82
|
+
const marker: SavedMarker = { id: createMarkerId(), name: markerName(state), updatedAt: Date.now(), state };
|
|
83
|
+
this.markers = saveMarker(marker);
|
|
84
|
+
this.activeMarkerId = marker.id;
|
|
85
|
+
this.image = null;
|
|
86
|
+
saveActiveMarkerId(marker.id);
|
|
87
|
+
this.setState(state);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
removeMarker = (id: string): void => {
|
|
91
|
+
this.markers = deleteMarker(id);
|
|
92
|
+
if (id !== this.activeMarkerId) return this.renderLibrary();
|
|
93
|
+
const next = this.markers[0];
|
|
94
|
+
if (next) this.selectMarker(next);
|
|
95
|
+
else this.addMarker();
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
render(): void {
|
|
99
|
+
drawTokenCanvas(this.elements.canvas, this.state, this.image);
|
|
100
|
+
renderFrameGallery(this.elements.frames, FRAME_PRESETS, this.state.frameId);
|
|
101
|
+
renderTextLayers(this.elements.textLayers, this.state.texts, this.state.selectedTextId, this.ui.noText);
|
|
102
|
+
this.elements.currentImage.textContent = this.state.imageName || this.ui.noImage;
|
|
103
|
+
this.elements.markerName.value = this.state.markerName || '';
|
|
104
|
+
this.setInputValues();
|
|
105
|
+
this.renderLibrary();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
setInputValues(): void {
|
|
109
|
+
const text = this.state.texts.find((item) => item.id === this.state.selectedTextId);
|
|
110
|
+
this.elements.textInput.value = text?.text || '';
|
|
111
|
+
this.elements.textSize.value = String(text?.size || 54);
|
|
112
|
+
this.elements.imageZoom.value = String(this.state.imageZoom);
|
|
113
|
+
this.elements.scale.value = String(this.state.scale);
|
|
114
|
+
this.elements.borderWidth.value = String(this.state.borderWidth);
|
|
115
|
+
this.elements.borderOpacity.value = String(this.state.borderOpacity);
|
|
116
|
+
this.elements.overlayOpacity.value = String(this.state.overlayOpacity);
|
|
117
|
+
this.setOutputValues();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
setOutputValues(): void {
|
|
121
|
+
const root = this.elements.root;
|
|
122
|
+
const output = (selector: string, value: string) => { const element = root.querySelector<HTMLOutputElement>(selector); if (element) element.value = value; };
|
|
123
|
+
output('[data-border-width-output]', String(this.state.borderWidth));
|
|
124
|
+
output('[data-border-opacity-output]', `${Math.round(this.state.borderOpacity * 100)}%`);
|
|
125
|
+
output('[data-overlay-opacity-output]', `${Math.round(this.state.overlayOpacity * 100)}%`);
|
|
126
|
+
output('[data-image-zoom-output]', `${Math.round(this.state.imageZoom * 100)}%`);
|
|
127
|
+
output('[data-scale-output]', `${Math.round(this.state.scale * 100)}%`);
|
|
128
|
+
output('[data-text-size-output]', String(this.state.texts.find((item) => item.id === this.state.selectedTextId)?.size || 54));
|
|
129
|
+
['background', 'border', 'text', 'overlay'].forEach((name) => {
|
|
130
|
+
const element = root.querySelector<HTMLInputElement>(`[data-color="${name}"]`);
|
|
131
|
+
const key = name === 'text' ? 'textColor' : name;
|
|
132
|
+
if (element) element.value = this.state[key as keyof TokenStampState] as string;
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
renderLibrary(): void {
|
|
137
|
+
renderMarkerLibrary(this.elements.library, this.markers, { emptyText: this.ui.noSavedMarkers, reuseText: this.ui.reuseMarker, deleteText: this.ui.deleteMarker, newText: this.ui.newMarker, activeId: this.activeMarkerId });
|
|
138
|
+
}
|
|
139
|
+
}
|