Skip to content
Merged
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,5 @@ firestore-debug.log
/playwright-report/
/playwright/.cache/
/playwright/.auth
CLAUDE.md
functions/.env.local
8 changes: 4 additions & 4 deletions src/components/editor/commands/Commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,8 +305,8 @@ export const StepForward: Command = {
keySymbol: '→',
active: (context) =>
context.evaluator.isInPast() &&
context.evaluator.getStepIndex() !== undefined &&
context.evaluator.getStepIndex() < context.evaluator.getStepCount()
context.evaluator.getStepIndex() !== undefined &&
context.evaluator.getStepIndex() < context.evaluator.getStepCount()
? true
: undefined,
execute: (context) => context.evaluator.stepWithinProgram(),
Expand Down Expand Up @@ -742,7 +742,7 @@ const Commands: Command[] = [
blocks
? view && getTokenViews
? (moveVisualVertical(-1, view, caret, getTokenViews) ??
false)
false)
: false
: (caret.moveVertical(-1) ?? true)
: false,
Expand Down Expand Up @@ -776,7 +776,7 @@ const Commands: Command[] = [
blocks
? view && getTokenViews
? (moveVisualVertical(1, view, caret, getTokenViews) ??
false)
false)
: false
: (caret.moveVertical(1) ?? true)
: false,
Expand Down
6 changes: 5 additions & 1 deletion src/components/editor/nodes/ExampleView.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,8 @@
<NodeView node={[node, 'open']} {format} /><NodeView
node={[node, 'program']}
{format}
/><NodeView node={[node, 'close']} {format} empty="hide" />
/><NodeView node={[node, 'close']} {format} empty="hide" /><NodeView
node={[node, 'highlight']}
{format}
empty="hide"
/>
69 changes: 68 additions & 1 deletion src/components/widgets/FormattedEditor.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import MarkupHTMLView from '@components/concepts/MarkupHTMLView.svelte';
import { toShortcut } from '@components/editor/commands/Commands';
import { locales } from '@db/Database';
import type LocaleText from '@locale/LocaleText';
import type { LocaleTextAccessor } from '@locale/Locales';
import Button from './Button.svelte';
import Switch from './Switch.svelte';
Expand All @@ -25,6 +26,59 @@
}: Props = $props();

let preview = $state(false);
let cursorPosition = $state(0);

$effect(() => {
if (!view) return;
function updateCursor() {
cursorPosition = view?.selectionStart ?? 0;
}
view.addEventListener('input', updateCursor);
view.addEventListener('click', updateCursor);
view.addEventListener('keyup', updateCursor);
return () => {
view?.removeEventListener('input', updateCursor);
view?.removeEventListener('click', updateCursor);
view?.removeEventListener('keyup', updateCursor);
};
});

function findExampleRange(
src: string,
cursor: number,
): { open: number; close: number } | null {
const positions: number[] = [];
let i = 0;
while (i < src.length) {
if (src[i] === '\\') positions.push(i);
i++;
}
for (let j = 0; j + 1 < positions.length; j += 2) {
const open = positions[j];
const close = positions[j + 1];
if (cursor > open && cursor <= close + 1) return { open, close };
}
return null;
}

let cursorInExample = $derived(
findExampleRange(text, cursorPosition) !== null,
);

function formatHighlight() {
if (view === undefined) return;
const cursor = view.selectionStart ?? 0;
const range = findExampleRange(text, cursor);
if (range === null) return;
const insertPos = range.close + 1;
text = text.slice(0, insertPos) + '⭐' + text.slice(insertPos);
const newCursor = insertPos + 1;
cursorPosition = newCursor;
setTimeout(() => {
view!.focus();
view!.setSelectionRange(newCursor, newCursor);
}, 0);
}

function format(format: '*' | '^' | '/' | '_' | '\\' | '@') {
if (view === undefined) return;
Expand All @@ -50,8 +104,9 @@
start + 1 + selected.length + (start === end ? 0 : 1);
}
}
// Update the text box's text.
// Update the text box's text and cursor position state.
text = formatted;
cursorPosition = cursorPos;

// Update the cursor position.
setTimeout(() => {
Expand Down Expand Up @@ -98,6 +153,11 @@
event.stopPropagation();
format('\\');
break;
case '*':
event.preventDefault();
event.stopPropagation();
formatHighlight();
break;
}
}
}
Expand Down Expand Up @@ -155,6 +215,13 @@
` (${toShortcut({ control: true, alt: undefined, shift: undefined, key: '\\' })})`}
action={() => format('\\')}><code>\\</code></Button
>
<Button
tip={(l: LocaleText) =>
l.ui.widget.formatted.highlight +
` (${toShortcut({ control: true, alt: undefined, shift: true, key: '8' })})`}
action={formatHighlight}
active={!preview && cursorInExample}><Emoji>⭐</Emoji></Button
>
<Button
tip={() =>
$locales.getPlainText((l) => l.token.Link) +
Expand Down
2 changes: 2 additions & 0 deletions src/locale/UITexts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,8 @@ type UITexts = {
edit: string;
/** [plain] The preview mode */
preview: string;
/** [plain] The highlight example button */
highlight: string;
};
};
/** Controls for the tiled windows in the project */
Expand Down
11 changes: 8 additions & 3 deletions src/locale/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,8 @@
"Name": "name",
"Locale": "locale",
"Unknown": "unknown",
"End": "end"
"End": "end",
"Highlight": "highlighted example"
},
"node": {
"Dimension": {
Expand Down Expand Up @@ -359,7 +360,10 @@
"emotion": "serious",
"doc": [
"You can put examples in your @Doc, like this:",
"\\¶Here's an example of adding: \\1 + 2\\¶3 + 4\\"
"\\¶Here's an example of adding: \\1 + 2\\¶3 + 4\\",
"If you are using Examples in your how-tos, you can tag one Example as a highlight.",
"\\¶Here's a highlighted example: \\1 + 2\\⭐¶3 + 4\\",
"The highlighted example is shown as the preview in the how-to space and the Guide."
]
},
"Mention": {
Expand Down Expand Up @@ -4341,7 +4345,8 @@
},
"formatted": {
"edit": "edit formatted text",
"preview": "preview formatted text"
"preview": "preview formatted text",
"highlight": "highlight example"
}
},
"tile": {
Expand Down
16 changes: 13 additions & 3 deletions src/nodes/Example.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type LocaleText from '@locale/LocaleText';
import type { NodeDescriptor } from '@locale/NodeTexts';
import { Purpose } from '../concepts/Purpose';
import Characters from '../lore/BasisCharacters';
import { CODE_SYMBOL } from '../parser/Symbols';
import { CODE_SYMBOL, HIGHLIGHT_SYMBOL } from '../parser/Symbols';
import Content from './Content';
import ExpressionPlaceholder from './ExpressionPlaceholder';
import { node, type Grammar, type Replacement } from './Node';
Expand All @@ -15,20 +15,28 @@ export default class Example extends Content {
readonly open: Token;
readonly program: Program;
readonly close: Token | undefined;
readonly highlight: Token | undefined;

constructor(open: Token, program: Program, close: Token | undefined) {
constructor(
open: Token,
program: Program,
close: Token | undefined,
highlight?: Token,
) {
super();

this.open = open;
this.program = program;
this.close = close;
this.highlight = highlight;
}

static make(program: Program) {
static make(program: Program, highlighted = false) {
return new Example(
new Token(CODE_SYMBOL, Sym.Code),
program,
new Token(CODE_SYMBOL, Sym.Code),
highlighted ? new Token(HIGHLIGHT_SYMBOL, Sym.Highlight) : undefined,
);
}

Expand All @@ -49,6 +57,7 @@ export default class Example extends Content {
{ name: 'open', kind: node(Sym.Code), label: undefined },
{ name: 'program', kind: node(Program), label: undefined },
{ name: 'close', kind: node(Sym.Code), label: undefined },
{ name: 'highlight', kind: node(Sym.Highlight), label: undefined },
];
}

Expand All @@ -61,6 +70,7 @@ export default class Example extends Content {
this.replaceChild('open', this.open, replace),
this.replaceChild('program', this.program, replace),
this.replaceChild('close', this.close, replace),
this.replaceChild('highlight', this.highlight, replace),
) as this;
}

Expand Down
1 change: 1 addition & 0 deletions src/nodes/Sym.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ const Sym = {
// Text literals can also come in multiple formats, to encode multilingual apps in place.
Text: 'text',
Code: '\\',
Highlight: '⭐',
// The optional negative sign allows for negative number literals.
// The optional dash allows for a random number range.
// The trailing text at the end encodes the unit.
Expand Down
38 changes: 38 additions & 0 deletions src/parser/Parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -449,3 +449,41 @@ test("commas in complex programs don't crash", { timeout: 120000 }, () => {
}
}
});

test('highlighted example with ⭐', () => {
const doc = parseDoc(toTokens('¶\\1 + 1\\⭐¶'));
const example = doc.markup.paragraphs[0].segments[0];
expect(example).toBeInstanceOf(Example);
expect((example as Example).highlight).toBeDefined();
expect((example as Example).highlight?.getText()).toBe('⭐');
});

test('highlighted example with highlight keyword', () => {
const doc = parseDoc(toTokens('¶\\1 + 1\\highlight¶'));
const example = doc.markup.paragraphs[0].segments[0];
expect(example).toBeInstanceOf(Example);
expect((example as Example).highlight).toBeDefined();
expect((example as Example).highlight?.getText()).toBe('⭐');
});

test('highlighted example with highlight prefix leaves remainder in stream', () => {
const doc = parseDoc(toTokens('¶\\1 + 1\\highlight more text¶'));
const paragraph = doc.markup.paragraphs[0];
const example = paragraph.segments[0];
expect(example).toBeInstanceOf(Example);
expect((example as Example).highlight).toBeDefined();
expect(paragraph.segments.length).toBeGreaterThan(1);
});

test('non-highlighted example has no highlight', () => {
const doc = parseDoc(toTokens('¶\\1 + 1\\¶'));
const example = doc.markup.paragraphs[0].segments[0];
expect(example).toBeInstanceOf(Example);
expect((example as Example).highlight).toBeUndefined();
});

test('highlighted example roundtrips to ⭐ form', () => {
const doc = parseDoc(toTokens('¶\\1 + 1\\highlight¶'));
const example = doc.markup.paragraphs[0].segments[0] as Example;
expect(example.toWordplay()).toBe('\\1+1\\⭐');
});
1 change: 1 addition & 0 deletions src/parser/Symbols.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ export const FORMATTED_TYPE_SYMBOL = '`…`';
export const PLACEHOLDER_SYMBOL = '_';
export const ETC_SYMBOL = '…';
export const CODE_SYMBOL = '\\';
export const HIGHLIGHT_SYMBOL = '⭐';
export const BASE_SYMBOL = ';';
export const BASE_SYMBOL_FULL = ';';
export const EXPONENT_SYMBOL = '^';
Expand Down
5 changes: 5 additions & 0 deletions src/parser/Tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,11 @@ export default class Tokens {
} while (condition());
}

/** Inject a token at the front of the unread queue (used when splitting a Words token). */
injectNext(token: Token): void {
this.#unread.unshift(token);
}

/** Rollback to the given token. */
unreadTo(token: Token) {
while (this.#read.length > 0 && this.#unread[0] !== token) {
Expand Down
31 changes: 23 additions & 8 deletions src/parser/parseMarkup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@ import Mention from '../nodes/Mention';
import type { Segment } from '../nodes/Paragraph';
import Paragraph from '../nodes/Paragraph';
import { Sym } from '../nodes/Sym';
import Token from '../nodes/Token';
import WebLink from '../nodes/WebLink';
import Words from '../nodes/Words';
import parseProgram from './parseProgram';
import { BULLET_SYMBOL } from './Symbols';
import { BULLET_SYMBOL, HIGHLIGHT_SYMBOL } from './Symbols';
import type Tokens from './Tokens';

export default function parseMarkup(tokens: Tokens): Markup {
Expand Down Expand Up @@ -47,12 +48,12 @@ function parseSegment(tokens: Tokens) {
return tokens.nextIs(Sym.TagOpen)
? parseWebLink(tokens)
: tokens.nextIs(Sym.Concept)
? parseConceptLink(tokens)
: tokens.nextIs(Sym.Code)
? parseExample(tokens)
: tokens.nextIs(Sym.Mention)
? parseMention(tokens)
: parseWords(tokens);
? parseConceptLink(tokens)
: tokens.nextIs(Sym.Code)
? parseExample(tokens)
: tokens.nextIs(Sym.Mention)
? parseMention(tokens)
: parseWords(tokens);
}

function parseWebLink(tokens: Tokens): WebLink {
Expand Down Expand Up @@ -126,7 +127,21 @@ export function parseExample(tokens: Tokens): Example {
const program = parseProgram(tokens, true);
const close = tokens.readIf(Sym.Code);

return new Example(open, program, close);
let highlight: Token | undefined = undefined;
if (close !== undefined && tokens.nextIs(Sym.Words)) {
const text = tokens.peekText() ?? '';
if (text.startsWith(HIGHLIGHT_SYMBOL) || text.startsWith('highlight')) {
tokens.read(Sym.Words);
const prefix = text.startsWith(HIGHLIGHT_SYMBOL)
? HIGHLIGHT_SYMBOL
: 'highlight';
highlight = new Token(HIGHLIGHT_SYMBOL, Sym.Highlight);
const remaining = text.slice(prefix.length);
if (remaining.length > 0) tokens.injectNext(new Token(remaining, Sym.Words));
}
}

return new Example(open, program, close, highlight);
}

function parseMention(tokens: Tokens): Mention | Branch {
Expand Down
7 changes: 5 additions & 2 deletions src/routes/gallery/[galleryid]/howto/HowToPreview.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,11 @@
$derived.by(() => {
let [markup, spaces] = toMarkup(text.join('\n\n'));

// step 1: determine if there are any examples in the how-to text
let example: Example | undefined = markup.getExamples()[0];
// step 1: determine which example to preview:
// prefer the first highlighted example, fall back to the first example
let examples = markup.getExamples();
let example: Example | undefined =
examples.find((e) => e.highlight !== undefined) ?? examples[0];

// step 2: if undefined, just get the first character. if no first character, emdash
if (!example) {
Expand Down
Loading
Loading