Skip to content

Commit 6e3443d

Browse files
[DONE] Pattern Lab 에서 글자를 글꼴 없이 도는 코드로 복사하고, 기기 헤더가 미리보기와 같은 그림을 낸다 (#186)
* 시작 — pf 63바퀴 * web: test text layers against the C++ header, compiled on the host Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fp7di6rhh5oFHoBU2khajT * web: Text panel copies the letters as code, and To hardware says a text stack needs no model Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fp7di6rhh5oFHoBU2khajT * docs: ARCHITECTURE names the Text panel's Copy as code and the header test Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fp7di6rhh5oFHoBU2khajT * 사진 — 63바퀴 Text 패널과 To hardware Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fp7di6rhh5oFHoBU2khajT * 사진을 걷는다 — 본문은 앞 커밋 주소로 본다 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fp7di6rhh5oFHoBU2khajT --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4bce0f2 commit 6e3443d

8 files changed

Lines changed: 803 additions & 33 deletions

File tree

‎web/ARCHITECTURE.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ A layered, dockable workspace: nine panels over a layer-stack project. A panel i
7171
| :--- | :--- | :--- |
7272
| Preview, Layers, Code, Knobs, Color Ramp | `panels/` | The editor proper. Layers are code (JS), pixel (RGBA buffer, edited in place and versioned by `rev`) or text; ramps are per code or text layer, knobs are one shared set |
7373
| Pixel | `panels/PixelPanel.tsx` + `panels/pixel/` + `lib/lab/pixelTools.ts` | Pixel-art editor. The panel composes one hook per concern (`usePixelToolState`, `usePixelSelection`, `usePixelViewport`, `usePixelCanvases`, `usePixelPointer`, `usePixelKeyboard`) and two toolbar components; `pixelToCode.ts` turns a sprite into standalone pattern code |
74-
| Text | `panels/TextPanel.tsx` + `lib/lab/text.ts`, `textRaster.ts`, `textFonts.ts` | Text layers. Words, size, place, alignment and font are drawn into the same RGBA buffer a pixel layer holds on every change, so the engine, masks, Graphic Export and both exports read a bitmap and never the font. Fonts are the site's own, the device's generic families, or a file the person picks, read with `FontFace` and kept in this browser's IndexedDB (`patternflow_lab_fonts_v1`) only when they ask. Drag on Preview moves the selected text |
74+
| Text | `panels/TextPanel.tsx` + `lib/lab/text.ts`, `textRaster.ts`, `textFonts.ts` | Text layers. Words, size, place, alignment and font are drawn into the same RGBA buffer a pixel layer holds on every change, so the engine, masks, Graphic Export and both exports read a bitmap and never the font. Fonts are the site's own, the device's generic families, or a file the person picks, read with `FontFace` and kept in this browser's IndexedDB (`patternflow_lab_fonts_v1`) only when they ask. Drag on Preview moves the selected text. Copy as code hands the drawn letters out as a pattern that needs no font, through Pixel's `lib/lab/pixelToCode.ts`. `lib/lab/textExport.test.ts` compiles the To hardware header with the host's C++ compiler and checks it against the preview |
7575
| Graphic Export | `panels/CapturePanel.tsx` + `lib/lab/capture/` | Stills and clips at print sizes, rendered by a second engine in a Web Worker (WebCodecs + `mediabunny`, all client-side), plus a GLSL shader twin for posters. `probe.ts` decides whether a pattern can be re-rendered at another size or must be upscaled |
7676
| Director | `panels/DirectorPanel.tsx` + `panels/director/` + `lib/lab/director/` | Knob automation over time — keyframes, bezier eases — baked to `.pfs` show files (`src/lib/pattern/pfst.ts` is the codec) and exported as MIDI. The panel composes `useTransportView`, `useTimelineZoom`, `useShowEditing`, the file helpers and two bars; the lanes are `DirectorLanes.tsx` |
7777

‎web/src/app/pattern-lab/HardwareModal.test.tsx‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,38 @@ describe("HardwareModal", () => {
155155
}
156156
});
157157

158+
// Text and pixel layers are in the scaffold as pixels, so with no code
159+
// layer the header is whole. The window used to count "0 layers to
160+
// translate — 0 done" and send the person to a model anyway.
161+
it("a stack of text alone is a finished header, with no model to ask", async () => {
162+
const lab = useLabStore.getState();
163+
const starter = lab.layers[0].id;
164+
lab.addTextLayer();
165+
useLabStore.getState().removeLayer(starter);
166+
expect(useLabStore.getState().layers.map((layer) => layer.type)).toEqual(["text"]);
167+
const writeText = vi.fn<(text: string) => Promise<void>>(() => Promise.resolve());
168+
Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText } });
169+
try {
170+
render(<Lab />);
171+
expect(screen.getByText(/Nothing here needs a model/)).toHaveTextContent("The board needs no font for the letters.");
172+
expect(screen.queryByText(/to translate/)).toBeNull();
173+
expect(screen.queryByText(/Translating it is a model/)).toBeNull();
174+
expect(screen.queryByText("Copy prompt")).toBeNull();
175+
176+
fireEvent.click(screen.getByText("Copy .h"));
177+
expect(await screen.findByText("Copied ✓")).toBeTruthy();
178+
const header = String(writeText.mock.calls[0][0]);
179+
expect(header).toMatch(/^#pragma once$/m);
180+
expect(header).toContain('"Text 1": text as drawn, RLE');
181+
expect(header).not.toContain("TRANSLATION BEGIN");
182+
183+
fireEvent.click(screen.getByText("Next →"));
184+
expect(screen.getByText("On to hardware")).toBeTruthy();
185+
} finally {
186+
delete (navigator as { clipboard?: unknown }).clipboard;
187+
}
188+
});
189+
158190
it("Esc over the Publish modal closes Publish alone and keeps the header", async () => {
159191
render(<Lab />);
160192
pasteHeaderAndContinue();

‎web/src/app/pattern-lab/HardwareModal.tsx‎

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { buildCppPrompt } from "@/lib/lab/cppPrompt";
99
import { currentPerformanceJson } from "@/lib/lab/director/publish";
1010
import { needsFlatten } from "@/lib/lab/flatten";
1111
import { labPatternName, useLabStore } from "@/lib/lab/store";
12-
import { isCodeLayer } from "@/lib/lab/types";
12+
import { isCodeLayer, isTextLayer } from "@/lib/lab/types";
1313
import styles from "./PatternLab.module.css";
1414
import local from "./HardwareModal.module.css";
1515

@@ -94,6 +94,10 @@ export default function HardwareModal({
9494
[stacked, exportData.scaffold, pastes, single, name],
9595
);
9696

97+
// Text and pixel layers go into the scaffold as the pixels they show, so
98+
// a stack with no code layer is the whole header, no model in the loop.
99+
const nothingToTranslate = stacked && exportData.units.length === 0;
100+
97101
const translated = exportData.units.filter(
98102
(unit) => (pastes[unit.index] ?? "").trim().length > 0 && cleanPastedUnit(pastes[unit.index] ?? "", unit.index) !== null,
99103
).length;
@@ -186,8 +190,10 @@ export default function HardwareModal({
186190
<p className={styles.modalNote}>
187191
The board runs C++, so everything below — building an image, installing a module,
188192
publishing as hardware-ready — needs this pattern as a <code>.h</code> header
189-
first. Translating it is a model&rsquo;s job: copy the prompt, paste what comes
190-
back.
193+
first.
194+
{nothingToTranslate
195+
? null
196+
: " Translating it is a model’s job: copy the prompt, paste what comes back."}
191197
</p>
192198

193199
{stacked ? (
@@ -202,11 +208,19 @@ export default function HardwareModal({
202208
spellCheck={false}
203209
/>
204210
</label>
205-
<p className={styles.modalNote}>
206-
{exportData.units.length} layer{exportData.units.length === 1 ? "" : "s"} to
207-
translate — {translated} done. Untranslated layers keep a compiling stub, so a
208-
partial port still builds.
209-
</p>
211+
{nothingToTranslate ? (
212+
<p className={styles.modalNote}>
213+
Nothing here needs a model. Text and pixel layers go into the header as the
214+
pixels they show, so it is finished as it is.
215+
{layers.some(isTextLayer) ? " The board needs no font for the letters." : null}
216+
</p>
217+
) : (
218+
<p className={styles.modalNote}>
219+
{exportData.units.length} layer{exportData.units.length === 1 ? "" : "s"} to
220+
translate — {translated} done. Untranslated layers keep a compiling stub, so a
221+
partial port still builds.
222+
</p>
223+
)}
210224
{exportData.units.map((unit) => (
211225
<div key={unit.index} className={local.hwUnit}>
212226
<div className={local.hwUnitHead}>

‎web/src/app/pattern-lab/panels/TextPanel.module.css‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -72,14 +72,16 @@
7272
height: 32px;
7373
}
7474

75-
.fontFile {
75+
.fontFile,
76+
.actions {
7677
display: flex;
7778
flex-wrap: wrap;
7879
align-items: center;
7980
gap: 6px 10px;
8081
}
8182

82-
.fontFile button {
83+
.fontFile button,
84+
.actions button {
8385
min-height: 32px;
8486
border-radius: 4px;
8587
border: 1px solid rgba(23, 21, 18, 0.3);
@@ -90,7 +92,8 @@
9092
cursor: pointer;
9193
}
9294

93-
.fontFile button:hover:not(:disabled) {
95+
.fontFile button:hover:not(:disabled),
96+
.actions button:hover:not(:disabled) {
9497
background: #171512;
9598
color: #f7f3ea;
9699
}
@@ -133,7 +136,8 @@
133136
@media (hover: none) {
134137
.field select,
135138
.field input[type="number"],
136-
.fontFile button {
139+
.fontFile button,
140+
.actions button {
137141
min-height: 44px;
138142
}
139143

‎web/src/app/pattern-lab/panels/TextPanel.test.tsx‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,56 @@ describe("TextPanel", () => {
132132
});
133133
});
134134

135+
describe("Copy as code", () => {
136+
function clipboard() {
137+
const writeText = vi.fn<(text: string) => Promise<void>>(() => Promise.resolve());
138+
Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText } });
139+
return writeText;
140+
}
141+
142+
it("copies the letters as they are drawn now, and the new ones after an edit", async () => {
143+
store().addTextLayer();
144+
store().setTextSpec(text().id, { text: "ab", x: 2, y: 4 });
145+
const writeText = clipboard();
146+
render(<TextPanel />);
147+
fireEvent.click(screen.getByRole("button", { name: "Copy as code" }));
148+
await act(async () => {});
149+
expect(screen.getByRole("button", { name: "Copied ✓" })).toBeTruthy();
150+
const first = String(writeText.mock.calls[0][0]);
151+
expect(first).toContain('// Text: "ab"');
152+
// Two letters, a 2×2 block each: a 4 × 2 sprite.
153+
expect(first).toContain("const SPRITE_W = 4, SPRITE_H = 2;");
154+
155+
fireEvent.change(screen.getByRole("textbox", { name: "Text" }), { target: { value: "abcd" } });
156+
fireEvent.click(screen.getByRole("button", { name: "Copied ✓" }));
157+
const second = String(writeText.mock.calls[1][0]);
158+
expect(second).toContain('// Text: "abcd"');
159+
expect(second).toContain("const SPRITE_W = 8, SPRITE_H = 2;");
160+
});
161+
162+
it("says so when no letter is on the frame", () => {
163+
store().addTextLayer();
164+
store().setTextSpec(text().id, { text: "" });
165+
const writeText = clipboard();
166+
render(<TextPanel />);
167+
fireEvent.click(screen.getByRole("button", { name: "Copy as code" }));
168+
expect(writeText).not.toHaveBeenCalled();
169+
expect(screen.getByRole("button", { name: "No letters on the frame" })).toBeTruthy();
170+
});
171+
172+
it("says so when the browser refuses the clipboard", async () => {
173+
store().addTextLayer();
174+
Object.defineProperty(navigator, "clipboard", {
175+
configurable: true,
176+
value: { writeText: () => Promise.reject(new Error("denied")) },
177+
});
178+
render(<TextPanel />);
179+
fireEvent.click(screen.getByRole("button", { name: "Copy as code" }));
180+
await act(async () => {});
181+
expect(screen.getByRole("button", { name: "Copy failed" })).toBeTruthy();
182+
});
183+
});
184+
135185
describe("dragging text on Preview", () => {
136186
it("moves the selected text by whole LED pixels", () => {
137187
store().addTextLayer();

‎web/src/app/pattern-lab/panels/TextPanel.tsx‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,13 @@
66
//
77
// Fonts come from the site, the device or a file the person picks
88
// (lib/lab/textFonts.ts). A file is read in this browser and goes nowhere.
9+
//
10+
// Copy as code takes the letters as they are drawn now, the way Pixel's does
11+
// a drawing (lib/lab/pixelToCode.ts): a pattern that needs no font.
912

1013
import { useEffect, useRef, useState, type ChangeEvent } from "react";
14+
import { useResettingState } from "@/lib/hooks/useResettingState";
15+
import { buildPixelPatternCode } from "@/lib/lab/pixelToCode";
1116
import { useActiveLayer, useLabStore } from "@/lib/lab/store";
1217
import {
1318
FONT_FILE_ACCEPT,
@@ -32,6 +37,7 @@ import {
3237
type TextFill,
3338
type TextLayer,
3439
} from "@/lib/lab/types";
40+
import type { CodeCopyState } from "./pixel/PixelToolbars";
3541
import local from "./TextPanel.module.css";
3642
import dock from "../LabPanels.module.css";
3743

@@ -49,6 +55,13 @@ const FILL_LABEL: Record<TextFill, string> = {
4955

5056
const ALIGN_LABEL = { left: "Left", center: "Center", right: "Right" } as const;
5157

58+
const COPY_LABEL: Record<CodeCopyState, string> = {
59+
idle: "Copy as code",
60+
copied: "Copied ✓",
61+
empty: "No letters on the frame",
62+
failed: "Copy failed",
63+
};
64+
5265
/** The font picker's entries, kept up as fonts are added, remembered or arrive. */
5366
function useFontOptions(): FontOption[] {
5467
const [options, setOptions] = useState(fontOptions);
@@ -136,9 +149,22 @@ function TextEditor({ layer }: { layer: TextLayer }) {
136149
const [remember, setRemember] = useState(false);
137150
const [busy, setBusy] = useState(false);
138151
const [message, setMessage] = useState<string | null>(null);
152+
const [codeCopy, flashCodeCopy] = useResettingState<CodeCopyState>("idle", 1400);
139153
const { spec } = layer;
140154
const set = (patch: Parameters<typeof setTextSpec>[1]) => setTextSpec(layer.id, patch);
141155

156+
const copyAsCode = () => {
157+
const code = buildPixelPatternCode(layer);
158+
if (!code) {
159+
flashCodeCopy("empty");
160+
return;
161+
}
162+
navigator.clipboard
163+
.writeText(code)
164+
.then(() => flashCodeCopy("copied"))
165+
.catch(() => flashCodeCopy("failed"));
166+
};
167+
142168
const missing = resolveFont(spec.font).missing;
143169
const groups = (["site", "system", "file"] as const).map((group) => ({
144170
group,
@@ -318,6 +344,17 @@ function TextEditor({ layer }: { layer: TextLayer }) {
318344
? "Drag the text in Preview to move it."
319345
: "Drag the text in Preview to move it. The colors are this layer's Color Ramp."}
320346
</p>
347+
348+
<div className={local.actions}>
349+
<button
350+
type="button"
351+
onClick={copyAsCode}
352+
title="Copy the letters as a standalone pattern (JS) that needs no font. Paste it into an AI chat and ask it to scroll, wave or twinkle them. The text layer stays as it is."
353+
>
354+
{COPY_LABEL[codeCopy]}
355+
</button>
356+
<span className={local.note}>The letters as pixels: the code needs no font. Change the words and copy again.</span>
357+
</div>
321358
</div>
322359
</div>
323360
);

0 commit comments

Comments
 (0)