-
Notifications
You must be signed in to change notification settings - Fork 0
feat(ui): ^FB Block-Text UX revamp + editor orphan-marker hint #94
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,69 @@ | ||
| import { useT } from "../../lib/useT"; | ||
| import { labelCls } from "./styles"; | ||
| import { NumberInput } from "./NumberInput"; | ||
| import { JustifyButtons } from "./JustifyButtons"; | ||
| import type { TextProps } from "../../registry/text"; | ||
|
|
||
| interface Props { | ||
| props: TextProps; | ||
| onChange: (patch: Partial<TextProps>) => void; | ||
| } | ||
|
|
||
| /** Block-text (`^FB`) sub-panel: width / max-lines / justify / | ||
| * line-spacing plus inline validation hints. Hints are purely | ||
| * informational — never auto-correct user-set blockLines, since | ||
| * content can legitimately have fewer lines than max (CSV-bound | ||
| * rows vary) or more (intentional truncation). The editor surfaces | ||
| * the mismatch but leaves the choice to the user. */ | ||
| export function BlockTextSettings({ props: p, onChange }: Props) { | ||
| const t = useT(); | ||
| const contentLines = p.content.split("\n").length; | ||
| const maxLines = p.blockLines ?? 1; | ||
| const truncates = contentLines > maxLines; | ||
| return ( | ||
| <> | ||
| <div className="grid grid-cols-2 gap-2"> | ||
| <NumberInput | ||
| label={t.registry.text.blockWidth} | ||
| value={p.blockWidth ?? 0} | ||
| min={1} | ||
| onChange={(blockWidth) => onChange({ blockWidth })} | ||
| /> | ||
| <NumberInput | ||
| label={t.registry.text.blockLines} | ||
| value={p.blockLines ?? 1} | ||
| min={1} | ||
| onChange={(blockLines) => onChange({ blockLines })} | ||
| /> | ||
| </div> | ||
| <div className="grid grid-cols-2 gap-2"> | ||
| <div className="flex flex-col gap-1"> | ||
| <label className={labelCls}>{t.registry.text.blockJustify}</label> | ||
| <JustifyButtons | ||
| value={p.blockJustify ?? "L"} | ||
| onChange={(blockJustify) => onChange({ blockJustify })} | ||
| /> | ||
| </div> | ||
| <NumberInput | ||
| label={t.registry.text.blockLineSpacing} | ||
| value={p.blockLineSpacing ?? 0} | ||
| onChange={(blockLineSpacing) => onChange({ blockLineSpacing })} | ||
| /> | ||
| </div> | ||
| {truncates && ( | ||
| <p className="font-mono text-[10px] text-warning"> | ||
| {t.registry.text.blockLinesExceededFmt | ||
| .replaceAll("{n}", String(contentLines)) | ||
| .replaceAll("{max}", String(maxLines))} | ||
| </p> | ||
| )} | ||
| {!truncates && contentLines < maxLines && ( | ||
| <p className="font-mono text-[10px] text-muted"> | ||
| {t.registry.text.blockLinesUsageFmt | ||
| .replaceAll("{n}", String(contentLines)) | ||
| .replaceAll("{max}", String(maxLines))} | ||
| </p> | ||
| )} | ||
| </> | ||
| ); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,84 @@ | ||
| import type { ReactNode } from "react"; | ||
| import { useT } from "../../lib/useT"; | ||
| import type { TextProps } from "../../registry/text"; | ||
|
|
||
| type Justify = NonNullable<TextProps["blockJustify"]>; | ||
|
|
||
| interface Props { | ||
| value: Justify; | ||
| onChange: (next: Justify) => void; | ||
| } | ||
|
|
||
| /** Inline SVG glyphs for the four justify modes. Stroke uses | ||
| * `currentColor` so the active/inactive button colour drives the | ||
| * icon — single source of truth for theming. Inline SVG (vs. | ||
| * Unicode glyphs) avoids font-fallback tofu on systems without the | ||
| * niche math/arrow ranges installed. */ | ||
| const ICONS: Record<Justify, ReactNode> = { | ||
| L: ( | ||
| <svg viewBox="0 0 16 12" className="w-4 h-3 mx-auto" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"> | ||
| <line x1="1" y1="2" x2="15" y2="2" /> | ||
| <line x1="1" y1="6" x2="11" y2="6" /> | ||
| <line x1="1" y1="10" x2="13" y2="10" /> | ||
| </svg> | ||
| ), | ||
| C: ( | ||
| <svg viewBox="0 0 16 12" className="w-4 h-3 mx-auto" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"> | ||
| <line x1="1" y1="2" x2="15" y2="2" /> | ||
| <line x1="3" y1="6" x2="13" y2="6" /> | ||
| <line x1="2" y1="10" x2="14" y2="10" /> | ||
| </svg> | ||
| ), | ||
| R: ( | ||
| <svg viewBox="0 0 16 12" className="w-4 h-3 mx-auto" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"> | ||
| <line x1="1" y1="2" x2="15" y2="2" /> | ||
| <line x1="5" y1="6" x2="15" y2="6" /> | ||
| <line x1="3" y1="10" x2="15" y2="10" /> | ||
| </svg> | ||
| ), | ||
| J: ( | ||
| <svg viewBox="0 0 16 12" className="w-4 h-3 mx-auto" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"> | ||
| <line x1="1" y1="2" x2="15" y2="2" /> | ||
| <line x1="1" y1="6" x2="15" y2="6" /> | ||
| <line x1="1" y1="10" x2="15" y2="10" /> | ||
| </svg> | ||
| ), | ||
| }; | ||
|
|
||
| /** ^FB text-justification toggle: 4 icon buttons (left / centre / | ||
| * right / justified) — same MS Word pattern users already know. | ||
| * Replaces the legacy `<select>` so picking takes a single click | ||
| * and the active mode is visually obvious at a glance. */ | ||
| export function JustifyButtons({ value, onChange }: Props) { | ||
| const t = useT(); | ||
| const items: { v: Justify; title: string }[] = [ | ||
| { v: "L", title: t.registry.text.justifyL }, | ||
| { v: "C", title: t.registry.text.justifyC }, | ||
| { v: "R", title: t.registry.text.justifyR }, | ||
| { v: "J", title: t.registry.text.justifyJ }, | ||
| ]; | ||
| return ( | ||
| <div className="flex gap-1" role="group" aria-label={t.registry.text.blockJustify}> | ||
| {items.map(({ v, title }) => { | ||
| const active = value === v; | ||
| return ( | ||
| <button | ||
| key={v} | ||
| type="button" | ||
| title={title} | ||
| aria-label={title} | ||
| aria-pressed={active} | ||
| onClick={() => onChange(v)} | ||
| className={`flex-1 px-2 py-1 rounded border transition-colors ${ | ||
| active | ||
| ? "border-accent bg-accent-dim text-accent" | ||
| : "border-border text-muted hover:text-text hover:bg-surface-2" | ||
| }`} | ||
| > | ||
| {ICONS[v]} | ||
| </button> | ||
| ); | ||
| })} | ||
| </div> | ||
| ); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,39 @@ | ||
| import { describe, it, expect } from "vitest"; | ||
| import { encodeFbContent, decodeFbContent } from "./fbContent"; | ||
|
|
||
| describe("fbContent encode/decode", () => { | ||
| it("encodes newlines as \\&", () => { | ||
| expect(encodeFbContent("line1\nline2")).toBe("line1\\&line2"); | ||
| }); | ||
|
|
||
| it("escapes literal backslash so \\& payloads round-trip", () => { | ||
| expect(encodeFbContent("a\\&b")).toBe("a\\\\&b"); | ||
| expect(decodeFbContent("a\\\\&b")).toBe("a\\&b"); | ||
| }); | ||
|
|
||
| it("decode is symmetric with encode", () => { | ||
| const samples = [ | ||
| "", | ||
| "plain", | ||
| "two\nlines", | ||
| "back\\slash", | ||
| "literal\\&marker", | ||
| "mixed\n\\&\nback\\slash", | ||
| "trailing\\", | ||
| ]; | ||
| for (const s of samples) { | ||
| expect(decodeFbContent(encodeFbContent(s))).toBe(s); | ||
| } | ||
| }); | ||
|
|
||
| it("passes unknown escapes through unchanged (legacy payloads)", () => { | ||
| expect(decodeFbContent("a\\xb")).toBe("a\\xb"); | ||
| }); | ||
|
|
||
| it("decodes legacy unescaped \\& to newline (pre-backslash-escape format)", () => { | ||
| // Payloads written before the encoder learned to escape `\` still | ||
| // emitted bare `\&` for newlines. Decoder must keep handling that | ||
| // so existing saved labels round-trip unchanged. | ||
| expect(decodeFbContent("line1\\&line2")).toBe("line1\nline2"); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,49 @@ | ||
| /** | ||
| * `^FB` block-text uses `\&` as the in-payload line-break marker (Zebra | ||
| * spec). The editor stores literal newlines in `content`; encode/decode | ||
| * translates between the two representations and is the single source of | ||
| * truth for both parser (decode) and generator (encode). | ||
| * | ||
| * To round-trip user payloads that happen to contain a literal `\&` | ||
| * sequence, `\` itself is escaped as `\\`. Decode reverses both | ||
| * substitutions in one scanning pass so order matters only inside the | ||
| * helper, not at the call sites. | ||
| */ | ||
|
|
||
| /** Encode an editor string for emission inside a ^FB payload. */ | ||
| export function encodeFbContent(s: string): string { | ||
| let out = ""; | ||
| for (const ch of s) { | ||
| if (ch === "\\") out += "\\\\"; | ||
| else if (ch === "\n") out += "\\&"; | ||
| else out += ch; | ||
| } | ||
| return out; | ||
| } | ||
|
|
||
| /** Decode a ^FB payload back to the editor representation. Symmetric | ||
| * with `encodeFbContent`; unknown `\x` sequences pass through literally | ||
| * so non-encoded legacy payloads survive without silent corruption. */ | ||
| export function decodeFbContent(s: string): string { | ||
| let out = ""; | ||
| let i = 0; | ||
| while (i < s.length) { | ||
| const ch = s[i]; | ||
| if (ch === "\\" && i + 1 < s.length) { | ||
| const next = s[i + 1]; | ||
| if (next === "\\") { | ||
| out += "\\"; | ||
| i += 2; | ||
| continue; | ||
| } | ||
| if (next === "&") { | ||
| out += "\n"; | ||
| i += 2; | ||
| continue; | ||
| } | ||
| } | ||
| out += ch; | ||
| i += 1; | ||
| } | ||
| return out; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.