@plannotator/ui 0.53.1 → 0.54.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/HANDOFF.md +217 -11
- package/README.md +65 -10
- package/components/AgentToolAnnouncementDialog.tsx +1 -1
- package/components/AgentsTab.tsx +1 -1
- package/components/AnnotationPanel.tsx +142 -24
- package/components/AnnotationSidebar.tsx +1 -1
- package/components/AnnotationToolbar.tsx +64 -9
- package/components/ApproveDropdown.tsx +7 -9
- package/components/AskSessionAnnouncementDialog.tsx +1 -1
- package/components/AttachmentsButton.tsx +1 -1
- package/components/BlockRenderer.tsx +19 -5
- package/components/CodeFilePopout.tsx +139 -4
- package/components/CommentPopover.tsx +44 -8
- package/components/DecisionControl.tsx +31 -5
- package/components/DiagramBlock.tsx +17 -12
- package/components/EditorAnnotationCard.tsx +1 -1
- package/components/ExportModal.tsx +77 -7
- package/components/HostTitle.tsx +0 -4
- package/components/ImageAnnotator/Toolbar.tsx +25 -1
- package/components/ImageAnnotator/index.tsx +24 -4
- package/components/ImageLightbox.tsx +658 -120
- package/components/ImagePeek.tsx +38 -4
- package/components/ImageRegions.tsx +369 -0
- package/components/ImageThumbnail.tsx +1 -1
- package/components/ImageZoomStage.tsx +796 -0
- package/components/InboxAnnouncementDialog.tsx +202 -0
- package/components/InlineMarkdown.tsx +152 -24
- package/components/KeyboardShortcuts.tsx +23 -0
- package/components/LazyBlockBoundary.tsx +100 -0
- package/components/ListMarker.tsx +3 -3
- package/components/LookAndFeelAnnouncementDialog.tsx +12 -2
- package/components/MenuVersionSection.tsx +31 -9
- package/components/PlanDecisionMenuExtras.tsx +129 -0
- package/components/PlanHeaderMenu.tsx +15 -1
- package/components/ProviderIcons.tsx +8 -0
- package/components/RefHoverCard.tsx +913 -0
- package/components/SendFailureToast.tsx +126 -0
- package/components/Settings.tsx +617 -126
- package/components/TerminalToolsAnnouncementDialog.tsx +1 -1
- package/components/ThemeProvider.tsx +14 -1
- package/components/ThemeTab.tsx +16 -24
- package/components/Tooltip.tsx +49 -18
- package/components/Viewer.tsx +280 -41
- package/components/ai/DocumentAIChatPanel.tsx +29 -3
- package/components/ai/NewChatButton.tsx +74 -0
- package/components/blocks/CodeBlock.tsx +1 -1
- package/components/blocks/HtmlBlock.tsx +7 -1
- package/components/blocks/QuestionBlock.tsx +522 -62
- package/components/diagram/DiagramComposer.tsx +5 -1
- package/components/diagram/DiagramPending.tsx +12 -2
- package/components/diagram/DiagramViewer.tsx +19 -3
- package/components/html-embed/HtmlEmbedBlock.tsx +374 -0
- package/components/html-embed/SvgFenceBlock.tsx +46 -0
- package/components/html-embed/index.ts +3 -0
- package/components/html-embed/languages.ts +15 -0
- package/components/html-embed/loadGate.ts +107 -0
- package/components/html-viewer/HtmlViewer.tsx +47 -6
- package/components/html-viewer/bridge-script.asset.js +173 -1
- package/components/html-viewer/bridge-script.ts +173 -1
- package/components/html-viewer/useHtmlAnnotation.ts +125 -0
- package/components/imageZoom.ts +265 -0
- package/components/plan-diff/PlanCleanDiffView.tsx +1 -1
- package/components/plan-diff/PlanDiffViewer.tsx +13 -2
- package/components/plan-diff/PlanRawDiffView.tsx +1 -1
- package/components/sidebar/FileBrowser.tsx +16 -2
- package/components/sidebar/SidebarTabs.tsx +3 -0
- package/config/index.ts +3 -0
- package/config/reviewView.ts +41 -1
- package/config/settings.ts +155 -4
- package/configure.ts +28 -0
- package/hooks/pfm/useCodeFilePopout.ts +7 -1
- package/hooks/useAIChat.ts +72 -25
- package/hooks/useAnnotationDraft.ts +71 -6
- package/hooks/useAnnotationHighlighter.ts +57 -3
- package/hooks/useCodeAnnotationDraft.ts +113 -17
- package/hooks/useCookieHostSetting.ts +96 -0
- package/hooks/useDocumentFont.ts +17 -0
- package/hooks/useDocumentTextSize.ts +20 -0
- package/hooks/useFileBrowser.ts +10 -1
- package/hooks/useLinkedDoc.ts +226 -13
- package/hooks/usePackageChangesSetting.ts +90 -0
- package/hooks/usePlanLookDialog.ts +67 -0
- package/hooks/useSharing.ts +11 -7
- package/hooks/useThemePaletteHalves.ts +37 -0
- package/hooks/useUpdateCheck.ts +22 -4
- package/hooks/useUpdateToast.ts +1 -1
- package/package.json +23 -22
- package/shortcuts/code-review/annotationToolbar.shortcuts.ts +1 -0
- package/shortcuts/code-review/explorer.shortcuts.ts +82 -0
- package/shortcuts/core.ts +63 -4
- package/shortcuts/index.ts +1 -0
- package/shortcuts/plan-review/annotationPanel.shortcuts.ts +1 -1
- package/shortcuts/plan-review/commentPopover.shortcuts.ts +1 -0
- package/shortcuts/runtime.ts +4 -3
- package/styles.css +1 -1
- package/theme.css +179 -1
- package/themes/plannotator-graphite.css +66 -0
- package/types.ts +26 -9
- package/utils/agentSwitch.ts +41 -20
- package/utils/agentToolSetting.ts +1 -1
- package/utils/aiChatFormat.ts +23 -7
- package/utils/annotationOwnsHighlight.ts +4 -2
- package/utils/asciidocParser.ts +570 -0
- package/utils/autoUpdateNotice.ts +24 -0
- package/utils/belowTitleSlot.ts +174 -0
- package/utils/codeLineScroll.ts +100 -0
- package/utils/commentSubmitKey.ts +48 -0
- package/utils/cssFontFamily.ts +65 -0
- package/utils/decisionSpec.ts +190 -2
- package/utils/defaultNotesApp.ts +1 -1
- package/utils/diffFonts.ts +4 -1
- package/utils/documentFont.ts +68 -0
- package/utils/documentTextSize.ts +54 -0
- package/utils/fontDetect.ts +63 -0
- package/utils/inboxAnnouncement.ts +78 -0
- package/utils/lookAndFeelAnnouncement.ts +82 -5
- package/utils/markdownExtensions.ts +2 -2
- package/utils/mermaid.ts +20 -3
- package/utils/mermaidTheme.ts +3 -51
- package/utils/notion.ts +58 -0
- package/utils/parser.ts +28 -1
- package/utils/platform.ts +46 -0
- package/utils/refCard.ts +82 -0
- package/utils/richClipboard.ts +380 -0
- package/utils/richClipboardLists.ts +57 -0
- package/utils/richCopyPolicy.ts +22 -0
- package/utils/scrollAnchor.ts +82 -0
- package/utils/sectionRefs.ts +123 -0
- package/utils/selectionBoundary.ts +40 -0
- package/utils/sendFailure.ts +85 -0
- package/utils/sharing.ts +5 -2
- package/utils/themeRegistry.ts +10 -0
- package/utils/themeTokens.ts +74 -0
- package/utils/updateNotice.ts +30 -8
- package/utils/vscodePanel.ts +9 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The document's font in plan review and annotate (Settings → Display →
|
|
3
|
+
* Document font, #1657): the family the document's text is drawn in. Empty by
|
|
4
|
+
* default, which keeps the theme's font (the document as it has always been
|
|
5
|
+
* drawn).
|
|
6
|
+
*
|
|
7
|
+
* The value is one of the generic presets below or the family name of a font
|
|
8
|
+
* installed on the machine, as for the code font (#851). A name always goes
|
|
9
|
+
* through `quoteFontFamily`, and the theme's sans font follows it, so a name
|
|
10
|
+
* that is not installed falls back to today's look.
|
|
11
|
+
*
|
|
12
|
+
* The app puts it on the document element (`applyDocumentFont`, through
|
|
13
|
+
* `hooks/useDocumentFont`) and packages/ui/theme.css hands it to the blocks of
|
|
14
|
+
* a document container (`data-doc-text-root`), the same scope as Text size.
|
|
15
|
+
* Code (inline code, code blocks, the raw diff) keeps the monospace font, and
|
|
16
|
+
* diagrams and the table / diagram popouts are left out.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { normalizeFontFamilyName, quoteFontFamily } from './cssFontFamily';
|
|
20
|
+
|
|
21
|
+
/** Generic stacks Settings offers by name. Every one always resolves. */
|
|
22
|
+
export const DOCUMENT_FONT_PRESETS = {
|
|
23
|
+
serif: 'ui-serif, Georgia, Cambria, "Times New Roman", Times, serif',
|
|
24
|
+
'sans-serif': 'ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif',
|
|
25
|
+
monospace: 'var(--font-mono), ui-monospace, monospace',
|
|
26
|
+
} as const;
|
|
27
|
+
|
|
28
|
+
/** The options of the Settings select, Theme Default first. */
|
|
29
|
+
export const DOCUMENT_FONT_OPTIONS = [
|
|
30
|
+
{ value: '', label: 'Theme Default' },
|
|
31
|
+
{ value: 'serif', label: 'Serif' },
|
|
32
|
+
{ value: 'sans-serif', label: 'Sans Serif' },
|
|
33
|
+
{ value: 'monospace', label: 'Monospace' },
|
|
34
|
+
] as const;
|
|
35
|
+
|
|
36
|
+
/** The attribute theme.css keys on; present only away from the default. */
|
|
37
|
+
export const DOCUMENT_FONT_ATTRIBUTE = 'data-doc-font';
|
|
38
|
+
|
|
39
|
+
/** The `font-family` value theme.css hands to the document's blocks. */
|
|
40
|
+
export const DOCUMENT_FONT_PROPERTY = '--doc-font-family';
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The `font-family` value for a stored setting: a preset's stack, or the
|
|
44
|
+
* quoted name followed by the theme's sans font. Empty for the default.
|
|
45
|
+
*/
|
|
46
|
+
export function documentFontStack(value: string | undefined): string {
|
|
47
|
+
const name = value ? normalizeFontFamilyName(value) : '';
|
|
48
|
+
if (!name) return '';
|
|
49
|
+
if (Object.hasOwn(DOCUMENT_FONT_PRESETS, name)) {
|
|
50
|
+
return DOCUMENT_FONT_PRESETS[name as keyof typeof DOCUMENT_FONT_PRESETS];
|
|
51
|
+
}
|
|
52
|
+
return `${quoteFontFamily(name)}, var(--font-sans), sans-serif`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Put `value` on `root` (the document element). At the default both the
|
|
57
|
+
* attribute and the property are removed, so nothing in theme.css applies.
|
|
58
|
+
*/
|
|
59
|
+
export function applyDocumentFont(root: HTMLElement, value: string | undefined): void {
|
|
60
|
+
const stack = documentFontStack(value);
|
|
61
|
+
if (!stack) {
|
|
62
|
+
root.removeAttribute(DOCUMENT_FONT_ATTRIBUTE);
|
|
63
|
+
root.style.removeProperty(DOCUMENT_FONT_PROPERTY);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
root.setAttribute(DOCUMENT_FONT_ATTRIBUTE, '');
|
|
67
|
+
root.style.setProperty(DOCUMENT_FONT_PROPERTY, stack);
|
|
68
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The document's text size in plan review and annotate (Settings → Display →
|
|
3
|
+
* Text size, #2066): the body text size in px, 15 by default (the document as
|
|
4
|
+
* it has always been drawn). Headings, lists, code, tables and question cards
|
|
5
|
+
* keep their proportions to it.
|
|
6
|
+
*
|
|
7
|
+
* The app puts it on the document element (`applyDocumentTextSize`, through
|
|
8
|
+
* `hooks/useDocumentTextSize`) and packages/ui/theme.css scales the blocks of
|
|
9
|
+
* a document container (`data-doc-text-root`) from `--doc-text-scale`. At the
|
|
10
|
+
* default nothing is set, so the page is exactly today's. Only the document's
|
|
11
|
+
* blocks scale: the header, panels, badges, menus and popouts keep their size.
|
|
12
|
+
* Raw HTML pages and diagrams draw themselves and do not follow it.
|
|
13
|
+
*
|
|
14
|
+
* Deliberately free of imports so the settings registry can read it on every
|
|
15
|
+
* surface (the guides.show viewer included).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** The steps Settings offers, in px of body text. */
|
|
19
|
+
export const DOCUMENT_TEXT_SIZE_OPTIONS = [13, 15, 17, 19, 21] as const;
|
|
20
|
+
|
|
21
|
+
export type DocumentTextSize = (typeof DOCUMENT_TEXT_SIZE_OPTIONS)[number];
|
|
22
|
+
|
|
23
|
+
/** Today's body text size: paragraphs are `text-[15px]`. */
|
|
24
|
+
export const DEFAULT_DOCUMENT_TEXT_SIZE: DocumentTextSize = 15;
|
|
25
|
+
|
|
26
|
+
/** The attribute index.css keys on; present only away from the default. */
|
|
27
|
+
export const DOCUMENT_TEXT_SIZE_ATTRIBUTE = 'data-doc-text-size';
|
|
28
|
+
|
|
29
|
+
/** The multiplier index.css scales by (size / 15). */
|
|
30
|
+
export const DOCUMENT_TEXT_SCALE_PROPERTY = '--doc-text-scale';
|
|
31
|
+
|
|
32
|
+
/** Only an offered step is a size; anything else reads as the default. */
|
|
33
|
+
export function isDocumentTextSize(value: unknown): value is DocumentTextSize {
|
|
34
|
+
return (DOCUMENT_TEXT_SIZE_OPTIONS as readonly unknown[]).includes(value);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The scale a size draws the document at, relative to today's. */
|
|
38
|
+
export function documentTextScale(size: DocumentTextSize): number {
|
|
39
|
+
return size / DEFAULT_DOCUMENT_TEXT_SIZE;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Put `size` on `root` (the document element). At the default both the
|
|
44
|
+
* attribute and the property are removed, so nothing in index.css applies.
|
|
45
|
+
*/
|
|
46
|
+
export function applyDocumentTextSize(root: HTMLElement, size: number): void {
|
|
47
|
+
if (!isDocumentTextSize(size) || size === DEFAULT_DOCUMENT_TEXT_SIZE) {
|
|
48
|
+
root.removeAttribute(DOCUMENT_TEXT_SIZE_ATTRIBUTE);
|
|
49
|
+
root.style.removeProperty(DOCUMENT_TEXT_SCALE_PROPERTY);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
root.setAttribute(DOCUMENT_TEXT_SIZE_ATTRIBUTE, String(size));
|
|
53
|
+
root.style.setProperty(DOCUMENT_TEXT_SCALE_PROPERTY, String(documentTextScale(size)));
|
|
54
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local font detection via width measurement.
|
|
3
|
+
*
|
|
4
|
+
* Renders a glyph-diverse sample string with the candidate family in front of
|
|
5
|
+
* each generic fallback (monospace, serif, sans-serif) and compares measured
|
|
6
|
+
* widths against the fallback alone. If any pair differs, the browser resolved
|
|
7
|
+
* the candidate font — it is installed (or already loaded as a webfont).
|
|
8
|
+
*
|
|
9
|
+
* This observes actual rendering rather than querying font-availability APIs,
|
|
10
|
+
* so it is deterministic for a given machine + browser + installed-font set.
|
|
11
|
+
* `document.fonts.check()` is NOT used: its spec answers "would this render
|
|
12
|
+
* without a font load," which browsers answer inconsistently for families the
|
|
13
|
+
* page never registered.
|
|
14
|
+
*
|
|
15
|
+
* Known limits:
|
|
16
|
+
* - The name must be the family name as the OS reports it ("Iosevka Custom",
|
|
17
|
+
* not a file name).
|
|
18
|
+
* - A font installed mid-session is picked up on the next call (no event).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { quoteFontFamily } from "./cssFontFamily";
|
|
22
|
+
|
|
23
|
+
const SAMPLE = "mmmmmmmmmmlli0O1Il@#WQ";
|
|
24
|
+
const SIZE = "72px";
|
|
25
|
+
const GENERICS = ["monospace", "serif", "sans-serif"] as const;
|
|
26
|
+
|
|
27
|
+
let ctx: CanvasRenderingContext2D | null | undefined;
|
|
28
|
+
|
|
29
|
+
function getContext(): CanvasRenderingContext2D | null {
|
|
30
|
+
if (ctx !== undefined) return ctx;
|
|
31
|
+
try {
|
|
32
|
+
ctx = document.createElement("canvas").getContext("2d");
|
|
33
|
+
} catch {
|
|
34
|
+
ctx = null;
|
|
35
|
+
}
|
|
36
|
+
return ctx;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Check whether a font family resolves on this machine.
|
|
41
|
+
*
|
|
42
|
+
* Returns `true` / `false` from width measurement, or `null` when measurement
|
|
43
|
+
* is unavailable (no canvas) — callers should treat `null` as "unknown" and
|
|
44
|
+
* skip any installed/missing hint.
|
|
45
|
+
*/
|
|
46
|
+
export function isFontInstalled(
|
|
47
|
+
family: string,
|
|
48
|
+
context: Pick<CanvasRenderingContext2D, "font" | "measureText"> | null = getContext(),
|
|
49
|
+
): boolean | null {
|
|
50
|
+
const quoted = quoteFontFamily(family);
|
|
51
|
+
if (!quoted) return false;
|
|
52
|
+
|
|
53
|
+
const c = context;
|
|
54
|
+
if (!c) return null;
|
|
55
|
+
|
|
56
|
+
for (const generic of GENERICS) {
|
|
57
|
+
c.font = `${SIZE} ${generic}`;
|
|
58
|
+
const base = c.measureText(SAMPLE).width;
|
|
59
|
+
c.font = `${SIZE} ${quoted}, ${generic}`;
|
|
60
|
+
if (c.measureText(SAMPLE).width !== base) return true;
|
|
61
|
+
}
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One-time gate for the Plannotator Inbox announcement. Cookie-backed like the
|
|
3
|
+
* other announcement gates, so a dismissal survives Plannotator's random
|
|
4
|
+
* localhost ports, and shared by the plan editor, the annotate surfaces and
|
|
5
|
+
* the code review editor: dismissing it anywhere retires it everywhere.
|
|
6
|
+
*
|
|
7
|
+
* A plain storage key rather than a settings-registry entry for the same reason
|
|
8
|
+
* as terminalToolsAnnouncement.ts: configStore seeds every registry default
|
|
9
|
+
* into a cookie on first access, so a registry flag could not tell "never seen"
|
|
10
|
+
* from "seeded default".
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { storage } from './storage';
|
|
14
|
+
|
|
15
|
+
const STORAGE_KEY = 'plannotator-announce-inbox-seen';
|
|
16
|
+
// Bump to re-announce after a meaningful revision.
|
|
17
|
+
const CURRENT_VERSION = '1';
|
|
18
|
+
|
|
19
|
+
export function needsInboxAnnouncement(): boolean {
|
|
20
|
+
return storage.getItem(STORAGE_KEY) !== CURRENT_VERSION;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function markInboxAnnouncementSeen(): void {
|
|
24
|
+
storage.setItem(STORAGE_KEY, CURRENT_VERSION);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The page runs inside the VS Code extension's webview, whose theme bridge sets
|
|
29
|
+
* `window.__PLANNOTATOR_VSCODE` before the app script runs (the same flag
|
|
30
|
+
* useEditorAnnotations reads). Read per call, not at module load, so a test can
|
|
31
|
+
* set it.
|
|
32
|
+
*/
|
|
33
|
+
export function isVSCodeWebview(): boolean {
|
|
34
|
+
return typeof window !== 'undefined'
|
|
35
|
+
&& (window as { __PLANNOTATOR_VSCODE?: unknown }).__PLANNOTATOR_VSCODE === true;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface InboxAnnouncementGateState {
|
|
39
|
+
/** Latched at mount from needsInboxAnnouncement(). */
|
|
40
|
+
readonly announcementPending: boolean;
|
|
41
|
+
/** The app has not finished loading its initial payload. */
|
|
42
|
+
readonly isLoading: boolean;
|
|
43
|
+
/**
|
|
44
|
+
* Archive browsing, a read-only shared plan, or no Plannotator server (the
|
|
45
|
+
* share portal and its demo). Deferred, not consumed.
|
|
46
|
+
*/
|
|
47
|
+
readonly readOnlySession: boolean;
|
|
48
|
+
/** Inside the VS Code extension's webview (isVSCodeWebview). Deferred, not consumed. */
|
|
49
|
+
readonly vscodeWebview: boolean;
|
|
50
|
+
/** Any other first-run dialog is on screen. The chain dialogs never stack. */
|
|
51
|
+
readonly otherFirstRunDialogVisible: boolean;
|
|
52
|
+
/**
|
|
53
|
+
* The terminal-tools announcement takes this load: it is pending and can
|
|
54
|
+
* show on this surface, or it was shown on this load. Then this one waits
|
|
55
|
+
* for the next load, so a reader never gets two announcements in a row.
|
|
56
|
+
*/
|
|
57
|
+
readonly earlierAnnouncementTakesThisLoad: boolean;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Whether the Inbox announcement may open now. After the terminal-tools
|
|
62
|
+
* announcement (never on the same load) and before "Ask this session" and the
|
|
63
|
+
* agent-tool offer, which wait while this one takes the load. Unlike its
|
|
64
|
+
* siblings it is not deferred on the compact touch shell: the panel is a video
|
|
65
|
+
* and one link, and the Inbox is read on a phone too. The Apps pass this
|
|
66
|
+
* through useFirstRunAnnouncementWindow, so it only opens before the reader
|
|
67
|
+
* has started working.
|
|
68
|
+
*/
|
|
69
|
+
export function inboxAnnouncementEligible(state: InboxAnnouncementGateState): boolean {
|
|
70
|
+
return (
|
|
71
|
+
state.announcementPending &&
|
|
72
|
+
!state.isLoading &&
|
|
73
|
+
!state.readOnlySession &&
|
|
74
|
+
!state.vscodeWebview &&
|
|
75
|
+
!state.otherFirstRunDialogVisible &&
|
|
76
|
+
!state.earlierAnnouncementTakesThisLoad
|
|
77
|
+
);
|
|
78
|
+
}
|
|
@@ -2,21 +2,98 @@
|
|
|
2
2
|
* Tracks whether the user has explicitly resolved the Grid/Clean plan choice.
|
|
3
3
|
* This is separate from `gridEnabled`: ConfigStore seeds that preference with
|
|
4
4
|
* its default on first access, which is not evidence that the user chose it.
|
|
5
|
+
* So `gridEnabled` trusts its stored value only once this decision is resolved
|
|
6
|
+
* (see `config/settings.ts`); before that, everyone gets the default, Clean.
|
|
7
|
+
*
|
|
8
|
+
* Two generations of marker:
|
|
9
|
+
* - v1: `plannotator-plan-look-choice-resolved=true`, or the 0.20.0 chooser's
|
|
10
|
+
* `plannotator-look-feel-announcement-seen=2`. Both choosers opened with
|
|
11
|
+
* Grid preselected, so a v1 marker next to a stored Grid does not tell a
|
|
12
|
+
* real Grid choice from a Continue on the preselected default.
|
|
13
|
+
* - v2: `plannotator-plan-look-v2` = `resolved` (a choice made since Clean
|
|
14
|
+
* became the default: the chooser's Continue or Escape, or the Settings
|
|
15
|
+
* switch) or `notice` (moved to Clean once, the notice not yet seen).
|
|
16
|
+
*
|
|
17
|
+
* The owner's decision (2026-10-27): everyone holding only a v1 marker with
|
|
18
|
+
* Grid stored moves to Clean once and sees a one-time notice (the chooser,
|
|
19
|
+
* Clean preselected); Clean users are marked v2 silently. v2 lives under its
|
|
20
|
+
* own key, and resolving still writes the v1 key, so an older Plannotator
|
|
21
|
+
* build sharing the cookie jar never re-asks and can never write a v1 marker
|
|
22
|
+
* over a v2 choice (which would move that person again).
|
|
5
23
|
*/
|
|
6
24
|
|
|
7
25
|
import { storage } from './storage';
|
|
8
26
|
|
|
9
27
|
const CHOICE_RESOLVED_KEY = 'plannotator-plan-look-choice-resolved';
|
|
10
28
|
const LEGACY_ANNOUNCEMENT_KEY = 'plannotator-look-feel-announcement-seen';
|
|
29
|
+
const V2_KEY = 'plannotator-plan-look-v2';
|
|
30
|
+
const GRID_KEY = 'plannotator-grid-enabled';
|
|
31
|
+
|
|
32
|
+
/** Which plan-look dialog this person needs: the first-run chooser, the one-time "moved to Clean" notice, or none. */
|
|
33
|
+
export type PlanLookPrompt = 'first-run' | 'moved' | null;
|
|
34
|
+
|
|
35
|
+
function hasV1Marker(): boolean {
|
|
36
|
+
return storage.getItem(CHOICE_RESOLVED_KEY) === 'true'
|
|
37
|
+
|| storage.getItem(LEGACY_ANNOUNCEMENT_KEY) === '2';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Upgrade a v1 marker to v2, once. Grid stored → Clean, notice pending;
|
|
42
|
+
* anything else → resolved, silently. Idempotent: does nothing once v2 is set
|
|
43
|
+
* or when there is no marker at all (a first run).
|
|
44
|
+
*/
|
|
45
|
+
export function migratePlanLookChoice(): void {
|
|
46
|
+
if (storage.getItem(V2_KEY) !== null) return;
|
|
47
|
+
if (!hasV1Marker()) return;
|
|
48
|
+
if (storage.getItem(GRID_KEY) === 'true') {
|
|
49
|
+
storage.setItem(GRID_KEY, 'false');
|
|
50
|
+
storage.setItem(V2_KEY, 'notice');
|
|
51
|
+
} else {
|
|
52
|
+
storage.setItem(V2_KEY, 'resolved');
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The v2 marker after migration. A `resolved` read is written again: cookies
|
|
58
|
+
* last a year from their last write, and an older build can keep the v1
|
|
59
|
+
* marker and a stored Grid alive. If v2 expired first, the next load would
|
|
60
|
+
* move the person to Clean a second time.
|
|
61
|
+
*/
|
|
62
|
+
function readV2(): string | null {
|
|
63
|
+
migratePlanLookChoice();
|
|
64
|
+
const v2 = storage.getItem(V2_KEY);
|
|
65
|
+
if (v2 === 'resolved') storage.setItem(V2_KEY, 'resolved');
|
|
66
|
+
return v2;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** True once the person picked Grid or Clean under the Clean default (chooser, notice or Settings). */
|
|
70
|
+
export function isLookChoiceResolved(): boolean {
|
|
71
|
+
return readV2() === 'resolved';
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function planLookPrompt(): PlanLookPrompt {
|
|
75
|
+
const v2 = readV2();
|
|
76
|
+
if (v2 === 'resolved') return null;
|
|
77
|
+
return v2 === 'notice' ? 'moved' : 'first-run';
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The look another tab already chose, or null while the choice is still open.
|
|
82
|
+
* Two open tabs both show the chooser or notice; when one is answered, the
|
|
83
|
+
* other must adopt that answer, not write its own preselected Clean over a
|
|
84
|
+
* Grid picked in the first tab.
|
|
85
|
+
*/
|
|
86
|
+
export function planLookAnsweredElsewhere(): boolean | null {
|
|
87
|
+
if (storage.getItem(V2_KEY) !== 'resolved') return null;
|
|
88
|
+
return storage.getItem(GRID_KEY) === 'true';
|
|
89
|
+
}
|
|
11
90
|
|
|
12
91
|
export function needsLookAndFeelAnnouncement(): boolean {
|
|
13
|
-
|
|
14
|
-
// v2 was the old Grid/Clean chooser wrapped in the 0.20.0 announcement.
|
|
15
|
-
// A dismissal there resolved the same decision; migrate it without showing
|
|
16
|
-
// another dialog merely because the release framing was removed.
|
|
17
|
-
return storage.getItem(LEGACY_ANNOUNCEMENT_KEY) !== '2';
|
|
92
|
+
return planLookPrompt() !== null;
|
|
18
93
|
}
|
|
19
94
|
|
|
20
95
|
export function markLookAndFeelChoiceResolved(): void {
|
|
96
|
+
storage.setItem(V2_KEY, 'resolved');
|
|
97
|
+
// Older builds read only this key; without it they would ask again.
|
|
21
98
|
storage.setItem(CHOICE_RESOLVED_KEY, 'true');
|
|
22
99
|
}
|
|
@@ -13,14 +13,14 @@
|
|
|
13
13
|
* default, so nothing changes for a user with no config.
|
|
14
14
|
*
|
|
15
15
|
* The built-in set here is deliberately NARROWER than the annotatable set on
|
|
16
|
-
* the server (`.md`/`.mdx`/`.txt`/`.html`/`.htm` only) — widening it is a
|
|
16
|
+
* the server (`.md`/`.mdx`/`.txt`/`.adoc`/`.asciidoc`/`.html`/`.htm` only) — widening it is a
|
|
17
17
|
* separate decision. Extras are added on top of it.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import { normalizeMarkdownExtensions } from "@plannotator/core/annotatable";
|
|
21
21
|
|
|
22
22
|
/** Built-in extensions the renderer treats as openable local documents. */
|
|
23
|
-
const BUILTIN_LINKED_DOC_REGEX = /\.(mdx?|txt|html?)$/i;
|
|
23
|
+
const BUILTIN_LINKED_DOC_REGEX = /\.(mdx?|txt|adoc|asciidoc|html?)$/i;
|
|
24
24
|
|
|
25
25
|
let extraExtensions: string[] = [];
|
|
26
26
|
|
package/utils/mermaid.ts
CHANGED
|
@@ -11,9 +11,8 @@
|
|
|
11
11
|
* the lazy path IS Plannotator's own path: `packages/editor/App.tsx` no longer
|
|
12
12
|
* imports `./mermaid-eager`, so a plan with no diagram never downloads the
|
|
13
13
|
* runtime in a chunked build (the share portal, any host that bundles by
|
|
14
|
-
* route)
|
|
15
|
-
*
|
|
16
|
-
* itself and nothing is fetched. A host that wants the runtime registered at
|
|
14
|
+
* route), nor in the single-file builds, which carry it as a packed chunk
|
|
15
|
+
* their server serves on request (build/pack-app.ts). A host that wants the runtime registered at
|
|
17
16
|
* startup imports `./mermaid-eager`, which fills the slot at module
|
|
18
17
|
* evaluation; the slot then short-circuits this loader.
|
|
19
18
|
*
|
|
@@ -22,6 +21,16 @@
|
|
|
22
21
|
*/
|
|
23
22
|
import type { Mermaid, MermaidConfig } from 'mermaid';
|
|
24
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Width in px at which node labels wrap (#2064). Mermaid 12 lowered the
|
|
26
|
+
* default from 200 to 120, and at Plannotator's 16 px label text that wraps
|
|
27
|
+
* after about eight CJK characters and splits short Latin labels. 140 is the
|
|
28
|
+
* owner-approved middle: long labels still wrap, short ones stay on one line.
|
|
29
|
+
* The themed path (`buildMermaidConfig`) spreads this config, so both the
|
|
30
|
+
* themed and the host-fallback `initialize` carry it.
|
|
31
|
+
*/
|
|
32
|
+
export const MERMAID_LABEL_WRAP_WIDTH = 140;
|
|
33
|
+
|
|
25
34
|
/**
|
|
26
35
|
* Hoisted verbatim from the former module-scope `mermaid.initialize(...)` in
|
|
27
36
|
* MermaidBlock. Nothing in it reads a CSS token or the resolved mode.
|
|
@@ -61,6 +70,14 @@ export const MERMAID_CONFIG: MermaidConfig = {
|
|
|
61
70
|
flowchart: {
|
|
62
71
|
htmlLabels: true,
|
|
63
72
|
curve: 'basis',
|
|
73
|
+
wrappingWidth: MERMAID_LABEL_WRAP_WIDTH,
|
|
74
|
+
},
|
|
75
|
+
/**
|
|
76
|
+
* Mermaid 11 drew state labels with the flowchart width; Mermaid 12 gives
|
|
77
|
+
* state diagrams their own `wrappingWidth`, also 120, so it is pinned too.
|
|
78
|
+
*/
|
|
79
|
+
state: {
|
|
80
|
+
wrappingWidth: MERMAID_LABEL_WRAP_WIDTH,
|
|
64
81
|
},
|
|
65
82
|
};
|
|
66
83
|
|
package/utils/mermaidTheme.ts
CHANGED
|
@@ -70,6 +70,7 @@
|
|
|
70
70
|
*/
|
|
71
71
|
import type { Mermaid, MermaidConfig } from 'mermaid';
|
|
72
72
|
import { MERMAID_CONFIG } from './mermaid';
|
|
73
|
+
import { readThemeTokenValues } from './themeTokens';
|
|
73
74
|
import {
|
|
74
75
|
compositeOver,
|
|
75
76
|
contrastRatio,
|
|
@@ -183,57 +184,8 @@ const CANVAS_MUTED_ALPHA = 0.3;
|
|
|
183
184
|
* is the signal to keep the static config.
|
|
184
185
|
*/
|
|
185
186
|
export function readThemeTokens(el?: Element | null): MermaidThemeTokens | undefined {
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
if (!target) return undefined;
|
|
189
|
-
let computed: CSSStyleDeclaration;
|
|
190
|
-
try {
|
|
191
|
-
computed = getComputedStyle(target);
|
|
192
|
-
} catch {
|
|
193
|
-
return undefined;
|
|
194
|
-
}
|
|
195
|
-
const tokens: MermaidThemeTokens = {};
|
|
196
|
-
let probe: HTMLElement | null = null;
|
|
197
|
-
try {
|
|
198
|
-
for (const name of MERMAID_THEME_TOKEN_NAMES) {
|
|
199
|
-
let raw = '';
|
|
200
|
-
try {
|
|
201
|
-
raw = computed.getPropertyValue(`--${name}`).trim();
|
|
202
|
-
} catch {
|
|
203
|
-
raw = '';
|
|
204
|
-
}
|
|
205
|
-
if (!raw) continue;
|
|
206
|
-
if (name === 'font-sans') {
|
|
207
|
-
tokens[name] = raw;
|
|
208
|
-
continue;
|
|
209
|
-
}
|
|
210
|
-
if (parseCssColor(raw)) {
|
|
211
|
-
tokens[name] = raw;
|
|
212
|
-
continue;
|
|
213
|
-
}
|
|
214
|
-
// Unparsable as written: let the engine resolve it to a colour.
|
|
215
|
-
try {
|
|
216
|
-
if (!probe) {
|
|
217
|
-
probe = document.createElement('span');
|
|
218
|
-
probe.setAttribute('aria-hidden', 'true');
|
|
219
|
-
probe.style.position = 'absolute';
|
|
220
|
-
probe.style.width = '0';
|
|
221
|
-
probe.style.height = '0';
|
|
222
|
-
probe.style.overflow = 'hidden';
|
|
223
|
-
probe.style.pointerEvents = 'none';
|
|
224
|
-
target.appendChild(probe);
|
|
225
|
-
}
|
|
226
|
-
probe.style.color = `var(--${name})`;
|
|
227
|
-
const resolved = getComputedStyle(probe).color.trim();
|
|
228
|
-
if (resolved && parseCssColor(resolved)) tokens[name] = resolved;
|
|
229
|
-
} catch {
|
|
230
|
-
// leave the token absent
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
} finally {
|
|
234
|
-
probe?.remove();
|
|
235
|
-
}
|
|
236
|
-
if (!tokens.background || !tokens.foreground) return undefined;
|
|
187
|
+
const tokens: MermaidThemeTokens | undefined = readThemeTokenValues(MERMAID_THEME_TOKEN_NAMES, el, ['font-sans']);
|
|
188
|
+
if (!tokens?.background || !tokens.foreground) return undefined;
|
|
237
189
|
return tokens;
|
|
238
190
|
}
|
|
239
191
|
|
package/utils/notion.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { storage } from './storage';
|
|
2
|
+
|
|
3
|
+
const STORAGE_KEY_ENABLED = 'plannotator-notion-enabled';
|
|
4
|
+
const STORAGE_KEY_PARENT_PAGE_ID = 'plannotator-notion-parent-page-id';
|
|
5
|
+
const STORAGE_KEY_AUTOSAVE = 'plannotator-notion-autosave';
|
|
6
|
+
|
|
7
|
+
export interface NotionSettings {
|
|
8
|
+
enabled: boolean;
|
|
9
|
+
parentPageId: string;
|
|
10
|
+
autoSave: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Whether this server offers Notion (`notion: true` from /api/plan): it has
|
|
15
|
+
* NOTION_TOKEN and the session is only on this computer. Off until the app
|
|
16
|
+
* hears otherwise, so the option stays hidden everywhere else.
|
|
17
|
+
*/
|
|
18
|
+
let available = false;
|
|
19
|
+
|
|
20
|
+
export function setNotionAvailable(value: boolean): void {
|
|
21
|
+
available = value;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function isNotionAvailable(): boolean {
|
|
25
|
+
return available;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Extract and normalize a Notion page UUID from either an ID or a page URL. */
|
|
29
|
+
export function normalizeNotionPageId(value: string): string | null {
|
|
30
|
+
const match = value.trim().match(/[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}/i);
|
|
31
|
+
if (!match) return null;
|
|
32
|
+
const compact = match[0].replace(/-/g, '').toLowerCase();
|
|
33
|
+
return `${compact.slice(0, 8)}-${compact.slice(8, 12)}-${compact.slice(12, 16)}-${compact.slice(16, 20)}-${compact.slice(20)}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function getNotionSettings(): NotionSettings {
|
|
37
|
+
return {
|
|
38
|
+
enabled: storage.getItem(STORAGE_KEY_ENABLED) === 'true',
|
|
39
|
+
parentPageId: storage.getItem(STORAGE_KEY_PARENT_PAGE_ID) ?? '',
|
|
40
|
+
autoSave: storage.getItem(STORAGE_KEY_AUTOSAVE) === 'true',
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function saveNotionSettings(settings: NotionSettings): void {
|
|
45
|
+
storage.setItem(STORAGE_KEY_ENABLED, String(settings.enabled));
|
|
46
|
+
storage.setItem(STORAGE_KEY_PARENT_PAGE_ID, settings.parentPageId);
|
|
47
|
+
storage.setItem(STORAGE_KEY_AUTOSAVE, String(settings.autoSave));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The parent page to save under, or null when Notion is not offered, off, or has no valid page. */
|
|
51
|
+
export function getNotionParentPageId(settings: NotionSettings = getNotionSettings()): string | null {
|
|
52
|
+
if (!available || !settings.enabled) return null;
|
|
53
|
+
return normalizeNotionPageId(settings.parentPageId);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function isNotionConfigured(): boolean {
|
|
57
|
+
return getNotionParentPageId() !== null;
|
|
58
|
+
}
|
package/utils/parser.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { Block, Annotation, CodeAnnotation, EditorAnnotation, ImageAttachme
|
|
|
2
2
|
import { planDenyFeedback } from '@plannotator/core/feedback-templates';
|
|
3
3
|
import { resolveReplyParents } from '@plannotator/core/annotation-threads';
|
|
4
4
|
import { diagramAnchorLocationLine, parseDiagramAnchor } from '@plannotator/core/diagram-anchor';
|
|
5
|
+
import { imageRegionLocationLines, parseImageRegion, type ImageRegion } from '@plannotator/core/image-region';
|
|
5
6
|
import {
|
|
6
7
|
formatQuestionAnswerLines,
|
|
7
8
|
formatQuestionAnswerText,
|
|
@@ -779,6 +780,8 @@ const isElementPlaceholderQuote = (text: unknown): boolean =>
|
|
|
779
780
|
* element instead (`Feedback on the <nav> element — "Primary"`); every other
|
|
780
781
|
* annotation keeps the quote line exactly as before. */
|
|
781
782
|
const commentHeadingLine = (ann: any): string => {
|
|
783
|
+
const region = imageRegionOf(ann);
|
|
784
|
+
if (region) return `Feedback on a region of the image ${safeInline(region.src, 600)}`;
|
|
782
785
|
const element = elementSubject(ann);
|
|
783
786
|
return element ? `Feedback on ${element}` : `Feedback on: "${ann.originalText}"`;
|
|
784
787
|
};
|
|
@@ -1001,6 +1004,8 @@ export const exportAnnotationEntry = (ann: any, opts: ElementContextExportOption
|
|
|
1001
1004
|
default:
|
|
1002
1005
|
if (ann?.isQuickLabel) {
|
|
1003
1006
|
output += `[${ann.text}] ${commentHeadingLine(ann)}\n`;
|
|
1007
|
+
const region = imageRegionOf(ann);
|
|
1008
|
+
if (region) output += imageRegionExportLines(region);
|
|
1004
1009
|
if (ann.quickLabelTip) output += `> ${ann.quickLabelTip}\n`;
|
|
1005
1010
|
} else {
|
|
1006
1011
|
output += `${commentHeadingLine(ann)}\n${diagramLocationExportLine(ann)}> ${ann?.text ?? ''}\n`;
|
|
@@ -1021,12 +1026,28 @@ export const exportAnnotationEntry = (ann: any, opts: ElementContextExportOption
|
|
|
1021
1026
|
return output;
|
|
1022
1027
|
};
|
|
1023
1028
|
|
|
1029
|
+
/** The validated region of a comment on part of an image (#2100), or null
|
|
1030
|
+
* for every other annotation and for a malformed one (an older or foreign
|
|
1031
|
+
* writer), which then exports as the ordinary comment it also is. */
|
|
1032
|
+
const imageRegionOf = (ann: any): ImageRegion | null =>
|
|
1033
|
+
ann?.imageRegion == null ? null : parseImageRegion(ann.imageRegion);
|
|
1034
|
+
|
|
1035
|
+
/** The two location lines under a region comment (the approved prototype):
|
|
1036
|
+
* `Image: figures/status-colors.png (line 63)` then
|
|
1037
|
+
* `Region: x=1820 y=380 w=420 h=160 (of 3664×1453)` — the image as written,
|
|
1038
|
+
* its line, and the box in the image's own pixels. The src is collapsed to
|
|
1039
|
+
* one line and defused like every page-controlled string. */
|
|
1040
|
+
const imageRegionExportLines = (region: ImageRegion): string =>
|
|
1041
|
+
`${imageRegionLocationLines({ ...region, src: safeInline(region.src, 600) })}\n`;
|
|
1042
|
+
|
|
1024
1043
|
/** The location line under a comment made on a rendered diagram part:
|
|
1025
1044
|
* `Diagram node Approve? (D), line 4` — the part's own id (what the agent
|
|
1026
1045
|
* greps the fence for) and the DOCUMENT line that declares it. Emits
|
|
1027
1046
|
* nothing for every other annotation, keeping their output byte-identical;
|
|
1028
1047
|
* a malformed anchor (an older or foreign writer) is skipped, never thrown. */
|
|
1029
1048
|
const diagramLocationExportLine = (ann: any): string => {
|
|
1049
|
+
const region = imageRegionOf(ann);
|
|
1050
|
+
if (region) return imageRegionExportLines(region);
|
|
1030
1051
|
const anchor = ann?.diagramAnchor === undefined ? null : parseDiagramAnchor(ann.diagramAnchor);
|
|
1031
1052
|
if (anchor !== null) return `${safeInline(diagramAnchorLocationLine(anchor), 600)}\n`;
|
|
1032
1053
|
return hostLocationExportLine(ann);
|
|
@@ -1043,6 +1064,9 @@ const hostLocationExportLine = (ann: any): string => {
|
|
|
1043
1064
|
|
|
1044
1065
|
const lineLabelForAnnotation = (blocks: Block[], ann: any): string | null => {
|
|
1045
1066
|
if (!ann.blockId || ann.type === 'GLOBAL_COMMENT') return null;
|
|
1067
|
+
// A region names the line its image is written on, not its block's span.
|
|
1068
|
+
const region = imageRegionOf(ann);
|
|
1069
|
+
if (region?.sourceLine != null) return `line ${region.sourceLine}`;
|
|
1046
1070
|
if (typeof ann.blockId === 'string' && ann.blockId.startsWith('diff-block-')) return null;
|
|
1047
1071
|
const block = blocks.find(b => b.id === ann.blockId);
|
|
1048
1072
|
if (!block || typeof block.startLine !== 'number') return null;
|
|
@@ -1615,7 +1639,10 @@ export const formatDraftAnnotationsForAsk = (input: DraftAnnotationsForAsk): str
|
|
|
1615
1639
|
} else {
|
|
1616
1640
|
const subject = elementSubject(ann) ?? quote(ann.originalText);
|
|
1617
1641
|
const anchor = ann.diagramAnchor === undefined ? null : parseDiagramAnchor(ann.diagramAnchor);
|
|
1618
|
-
const
|
|
1642
|
+
const region = imageRegionOf(ann);
|
|
1643
|
+
const target = region
|
|
1644
|
+
? `a region of the image ${quote(region.src)} [${safeInline(imageRegionLocationLines(region).replace('\n', '; '), 300)}]`
|
|
1645
|
+
: anchor ? `${subject} [${safeInline(diagramAnchorLocationLine(anchor), 300)}]` : subject;
|
|
1619
1646
|
body = ann.isQuickLabel
|
|
1620
1647
|
? `label ${quote(ann.text)} on ${target}`
|
|
1621
1648
|
: `comment on ${target} — ${text(ann.text)}`;
|