Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions packages/headless/src/core/markdown.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1562,10 +1562,12 @@ describe("markdown bridge", () => {
};

const markdown = jsonToMarkdown(input);
expect(markdown).toContain("![Example](https://example.com/photo.jpg");
// Size is native markdown (Obsidian's alt-size); the rest rides in metadata.
expect(markdown).toContain("![Example|720x405](https://example.com/photo.jpg");
expect(markdown).toContain("luthor:meta v1");
expect(markdown).not.toContain('"width":720');

const editedMarkdown = markdown.replace("![Example]", "![Updated]");
const editedMarkdown = markdown.replace("![Example|720x405]", "![Updated|720x405]");
const roundTrip = markdownToJSON(editedMarkdown) as JsonDocument;
const imageNode = findTopLevelNode(roundTrip, "image") as {
src?: string;
Expand Down
3 changes: 3 additions & 0 deletions packages/headless/src/core/source-capability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@ export const MARKDOWN_NATIVE_KEY_MAP: Readonly<Record<string, ReadonlySet<string
"linkHref",
"linkTitle",
"alignment",
// Obsidian's `![alt|W](src)` / `|WxH` carries the size in markdown itself.
"width",
"height",
]),
"iframe-embed": new Set(["type", "version", "src", "caption"]),
"youtube-embed": new Set(["type", "version", "src", "caption"]),
Expand Down
47 changes: 39 additions & 8 deletions packages/headless/src/extensions/core/FloatingToolbarExtension.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,13 @@ function FloatingToolbarPlugin<TCommands = any, TStates = any>({
);
const [selection, setSelection] = useState<RangeSelection | null>(null);
const viewportRafIdRef = useRef<number | null>(null);
// The selection the person dismissed the bar for (an outside click). It stays
// dismissed for that selection — a collaborator's edit, a re-render, a scroll
// must not bring it back — until the selection itself changes.
const currentSignatureRef = useRef<string | null>(null);
const dismissedSignatureRef = useRef<string | null>(null);
// Which side of the selection the bar last sat on, for flip hysteresis.
const sideRef = useRef<"below" | "above">("below");

const getSelectionAnchorRect = (element: HTMLElement): DOMRect => {
const anchorElement = element.querySelector<HTMLElement>(
Expand Down Expand Up @@ -399,11 +406,21 @@ function FloatingToolbarPlugin<TCommands = any, TStates = any>({
positionFromRight = false;
}

/* Vertical bounds check */
if (y - toolbarHeight < scrollY) {
y = domRect.bottom + scrollY + offset.y;
} else if (y + toolbarHeight > scrollY + viewportHeight) {
y = domRect.top + scrollY - offset.y - toolbarHeight;
/* Vertical bounds check, with hysteresis */
const hysteresis = 8;
const belowY = domRect.bottom + scrollY + offset.y;
const aboveY = domRect.top + scrollY - offset.y - toolbarHeight;
const overflowBelow = belowY + toolbarHeight - (scrollY + viewportHeight);
const overflowAbove = scrollY - aboveY;
if (strategy !== "above") {
if (sideRef.current === "below" && overflowBelow > hysteresis && overflowAbove < 0) {
sideRef.current = "above";
} else if (sideRef.current === "above" && (overflowBelow < -hysteresis || overflowAbove > 0)) {
sideRef.current = "below";
}
y = sideRef.current === "above" ? aboveY : belowY;
} else if (y - toolbarHeight < scrollY) {
y = belowY;
}

return {
Expand Down Expand Up @@ -432,6 +449,19 @@ function FloatingToolbarPlugin<TCommands = any, TStates = any>({
let newRect: DOMRect | null = null;
let rangeSelection: RangeSelection | null = null;

const signature = $isRangeSelection(sel)
? `r:${sel.anchor.key}:${sel.anchor.offset}:${sel.focus.key}:${sel.focus.offset}`
: $isNodeSelection(sel)
? `n:${sel.getNodes().map((n) => n.getKey()).sort().join(",")}`
: null;
currentSignatureRef.current = signature;
if (signature !== dismissedSignatureRef.current) {
dismissedSignatureRef.current = null;
} else if (signature !== null) {
setIsVisible(false);
return;
}

if ($isRangeSelection(sel) && !sel.isCollapsed()) {
const domSelection = window.getSelection();
if (domSelection && domSelection.rangeCount > 0) {
Expand Down Expand Up @@ -505,12 +535,13 @@ function FloatingToolbarPlugin<TCommands = any, TStates = any>({
};
}, [editor, config.debounceMs, createSelectionRect, extension]);

/* Hide callback */
const hide = () => {
/* Hide callback — sticks until the selection changes */
const hide = useCallback(() => {
dismissedSignatureRef.current = currentSignatureRef.current;
setIsVisible(false);
setSelectionRect(null);
setSelection(null);
};
}, []);

/* Require render function to be provided */
if (!config.render) {
Expand Down
116 changes: 115 additions & 1 deletion packages/headless/src/extensions/embeds/EmbedResolverContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,97 @@ export interface SavedCardMetadata {
siteName?: string;
}

/**
* What a host knows about an attachment before it loads — enough to reserve its
* box (no layout shift), pick a thumbnail, and label a file card. Every field is
* optional; a host that knows nothing returns `undefined` and the embed measures
* itself on load instead.
*/
export interface MediaMeta {
/** `image`, `gif`, `video`, `audio`, `document` or `file`. */
kind?: string;
/** The type the file is served as (`image/png`, `application/pdf`). */
mime?: string;
/** Size in bytes. */
size?: number;
/** A content version; changes whenever the bytes do. */
version?: string;
/** Intrinsic width in CSS pixels, as displayed (EXIF rotation applied). */
width?: number | null;
/** Intrinsic height in CSS pixels, as displayed. */
height?: number | null;
/** Running time of audio/video, in milliseconds. */
durationMs?: number | null;
/** An animated image (GIF/WebP): never swap it for a still thumbnail. */
animated?: boolean;
/** A poster frame exists for this video (`variant: "poster"`). */
poster?: boolean;
/** Smaller renditions exist (`variant: "thumb"`). */
thumb?: boolean;
}

/** Which rendition of an attachment a URL should point at. */
export interface MediaUrlOptions {
/**
* `original` (default) — the file itself. `thumb` — a smaller still, at least
* `width` wide when possible. `poster` — a video's poster frame.
*/
variant?: "original" | "thumb" | "poster";
/** Desired width in CSS pixels for `thumb` / `poster`. */
width?: number;
}

/** What a host's file-card expansion renders from (see `renderFileExpansion`). */
export interface FileExpansionContext {
target: string;
/** Text after `#` in the embed (`page=3`), or empty. */
fragment: string;
url: string;
kind: string;
meta: MediaMeta | null | undefined;
}

/** An edit to an embedded attachment; `null` clears a field. */
export interface MediaEdit {
target?: string;
width?: number | null;
height?: number | null;
align?: "left" | "center" | "right" | null;
caption?: string | null;
alt?: string | null;
}

/** What a media toolbar item acts on (see `mediaToolbar`). */
export interface MediaToolbarContext {
target: string;
fragment: string;
/** `image`, `video`, `audio`, `pdf` or `file`. */
kind: string;
url: string;
meta: MediaMeta | null | undefined;
width?: number;
height?: number;
align?: "left" | "center" | "right";
caption?: string;
alt?: string;
/** Apply an edit to this embed (one undo step, one collaboration update). */
update: (edit: MediaEdit) => void;
/** Remove this embed from the document. */
remove: () => void;
}

/** One button in the media toolbar. */
export interface MediaToolbarItem {
id: string;
/** Accessible name, also the tooltip. */
label: string;
/** Icon; the label is shown as text when there is none. */
icon?: ReactNode;
active?: boolean;
disabled?: boolean;
onSelect: () => void;
}

/**
* Callbacks an embed node uses to reach host services. Every member is optional
* so the nodes degrade gracefully when a host wires only part of the surface (or
Expand All @@ -54,7 +145,30 @@ export interface EmbedResolvers {
* without a loading flash. When omitted, the file embed renders a reference
* chip instead of loading media.
*/
resolveMediaUrl?: (target: string) => string;
resolveMediaUrl?: (target: string, options?: MediaUrlOptions) => string;
/**
* Synchronous, cached metadata for a media target — `undefined` while unknown.
* Must return the *same object* for the same target until it changes (it is
* read through `useSyncExternalStore`). Pair with {@link subscribeMediaMeta}
* to have embeds re-render when a lookup lands.
*/
getMediaMeta?: (target: string) => MediaMeta | null | undefined;
/** Subscribe to metadata arriving/changing. Returns an unsubscribe function. */
subscribeMediaMeta?: (listener: () => void) => () => void;
/**
* Extra content under a non-image file card — e.g. an inline PDF viewer the
* host renders on demand. Return `null` for files it has nothing to add to.
*/
renderFileExpansion?: (context: FileExpansionContext) => ReactNode;
/**
* The toolbar shown on a selected attachment. `items` adds the host's own
* buttons (replace, download, …) after the built-in ones; `builtIn: false`
* drops the built-ins (align, size, caption, alt, open, remove).
*/
mediaToolbar?: {
builtIn?: boolean;
items?: (context: MediaToolbarContext) => MediaToolbarItem[];
};
/**
* Navigate to a link target (the `Target` inside `[[Target]]`). Invoked when a
* reader activates a wikilink. When omitted, the wikilink renders as inert
Expand Down
Loading
Loading