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
7 changes: 6 additions & 1 deletion apps/webapp/cypress/e2e/chatroom/attachments.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -623,6 +623,8 @@ describe('chatroom attachments', () => {
url: storagePath,
type: 'image',
name: 'secret.png',
width: 32,
height: 32,
spoiler: true
}
],
Expand All @@ -634,7 +636,10 @@ describe('chatroom attachments', () => {
assertImageControlReady()
waitForStorageSignIfPending()

cy.get(`[data-msg-id="${messageId}"] [data-testid="feed-spoiler-reveal"]`).should('exist')
// Match the storage fixture dimensions so image loading cannot move the native click target.
cy.get<HTMLImageElement>(
`[data-msg-id="${messageId}"] [data-testid="feed-spoiler-reveal"] img`
).should('have.prop', 'naturalWidth', 32)
cy.get(`[data-msg-id="${messageId}"] [data-testid="feed-spoiler-reveal"]`).realClick()
cy.get('[data-testid="chat-media-gallery"]').should('not.exist')
cy.get(`[data-msg-id="${messageId}"] [data-testid="feed-image-open"]`, {
Expand Down
2 changes: 1 addition & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion context7.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
"rules": [
"Use Bun for every package operation: `bun add`, `bun run`, `bunx`. Never npm, yarn, pnpm or npx, and never write `npm install` in an example.",
"Install a docs.plus Tiptap extension with `bun add @docs.plus/extension-<name>`. The five are extension-hyperlink, extension-hypermultimedia, extension-indent, extension-inline-code and extension-placeholder.",
"All five docs.plus extensions are at 2.0.0 and need @tiptap/core and @tiptap/pm at ^3.22.3.",
"Read each extension's README and package.json for its version and Tiptap peer requirements. Resolve catalog references from the root package.json.",
"With extension-hyperlink set `StarterKit.configure({ link: false })`. StarterKit v3 bundles its own link mark claiming the same commands and the same a[href] parse rule. The clash is silent.",
"With extension-inline-code set `StarterKit.configure({ code: false })`. Both marks render <code> and both bind Mod-e. The clash is silent.",
"With extension-placeholder, remove the Tiptap built-in placeholder from the extensions array. Both register the name `placeholder`.",
Expand Down
39 changes: 39 additions & 0 deletions extensions/extension-hyperlink/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,16 @@ editor.chain().focus().setHyperlink({ href: 'https://example.com' }).run()
editor.getAttributes('hyperlink').href // read the current href
```

### Links on images

The mark can wrap an inline image when the parent schema permits marks.
For `HyperMultimediaKit`, configure `Image: { inline: true }`, select the image, then call `setHyperlink({ href })`.
Use `editHyperlink({ newURL })` to change its URL without replacing the image with text.
HTML export and import keep the image inside its anchor.

The prebuilt edit form requires link text. Use a host URL form for image-only links.
Block media and custom node views need separate integration checks; they are not covered by this inline-image recipe.

## Keyboard shortcuts

| Shortcut | Context | Action |
Expand Down Expand Up @@ -323,6 +333,35 @@ Three ways to use them:
- **Open them from outside the editor** — call [the openers](#openers) from a toolbar button or a React component.
- **Replace one or all of them** — pass your own factory into the matching slot. See [Bring your own popover](#bring-your-own-popover). For a popover that is not anchored to a hyperlink, or to observe popover state from outside, see [Advanced](#advanced).

### Visible action labels

The prebuilt preview makes no metadata request and needs no `/api/metadata` endpoint.
Its buttons use icons, accessible names, and hover/focus tooltips.
To show text beside every icon, wrap the existing factory:

```ts
Hyperlink.configure({
popovers: {
previewHyperlink: (options) => {
const root = previewHyperlinkPopover(options)
root.style.flexWrap = 'wrap'
root.style.maxWidth = 'calc(100vw - 32px)'
for (const button of root.querySelectorAll('button')) {
const label = document.createElement('span')
label.textContent = button.getAttribute('aria-label')
button.append(label)
button.style.width = 'auto'
button.style.padding = '0 10px'
}
return root
}
}
})
```

This keeps the built-in actions and gives touch users visible labels without waiting for a tooltip.
For metadata cards, supply a custom preview factory and fetch metadata through your own service.

### Popover-factory option shapes

Every factory takes one `options` argument. The shape depends on the slot.
Expand Down
3 changes: 3 additions & 0 deletions extensions/extension-hyperlink/cypress/e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ Clean-room Cypress suite against `test/playground/main.ts` via `@docs.plus/playg

## Specs

Reported integrations also run through `transaction-edit.cy.ts` and `labeled-preview.cy.ts`.
These cover direct and chained edits, visible preview labels, and operation without a metadata service.

| Spec | What it proves |
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `create.cy.ts` | Mod-k create popover: lifecycle, URL validation/normalization (bare domains, phones, emails), collapsed-caret insert, documented DOM contract |
Expand Down
29 changes: 29 additions & 0 deletions extensions/extension-hyperlink/cypress/e2e/labeled-preview.cy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
describe('preview without a metadata service', () => {
it('shows visible action labels and removes a link with the documented factory wrapper', () => {
cy.viewport(375, 812)
const metadataRequest = cy.spy().as('metadataRequest')
cy.intercept('**/api/metadata*', metadataRequest)
cy.visit('/?popover=labels')
cy.window().should('have.property', '_editor')
cy.setEditorContent('<p><a href="https://example.com">Example</a></p>')
cy.get('#editor a').click()
cy.getVisibleFloatingPopover()
.find('.hyperlink-preview-popover')
.should(($preview) => {
const bounds = $preview[0].getBoundingClientRect()
expect(bounds.left).to.be.at.least(0)
expect(bounds.right).to.be.at.most(375)
})
.within(() => {
for (const label of ['Copy link', 'Edit link', 'Remove link']) {
cy.contains('button span', label).should('be.visible')
}
cy.contains('button', 'Remove link').click()
})
cy.get('#editor a').should('not.exist')
cy.get('#editor p').should('have.text', 'Example')
cy.get('@metadataRequest').should('not.have.been.called')
})
})

export {}
21 changes: 21 additions & 0 deletions extensions/extension-hyperlink/cypress/e2e/transaction-edit.cy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
describe('transaction-safe hyperlink edits', () => {
for (const mode of ['command', 'chain'] as const) {
it(`edits text and URL through the ${mode} entry point without a mismatched transaction`, () => {
cy.visitPlayground()
cy.setEditorContent(
'<p>Before <a href="https://example.com"><strong>Original</strong></a> after.</p>'
)
cy.selectText('Original')
cy.getEditor().then((editor) => {
const attrs = { newURL: 'https://google.com', newText: 'Testing' }
if (mode === 'command') editor.commands.editHyperlink(attrs)
else editor.chain().focus().extendMarkRange('hyperlink').editHyperlink(attrs).run()
})
cy.get('#editor a').should('have.attr', 'href', 'https://google.com')
cy.get('#editor a strong').should('have.text', 'Testing')
cy.get('#editor p').should('have.text', 'Before Testing after.')
})
}
})

export {}
20 changes: 19 additions & 1 deletion extensions/extension-hyperlink/test/playground/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,11 +108,29 @@ function byoPreviewHyperlink(options: HyperlinkModule.PreviewHyperlinkOptions):
return root
}

function labeledPreviewHyperlink(options: HyperlinkModule.PreviewHyperlinkOptions): HTMLElement {
const root = previewHyperlinkPopover(options)
root.style.flexWrap = 'wrap'
root.style.maxWidth = 'calc(100vw - 32px)'
for (const button of root.querySelectorAll('button')) {
const label = document.createElement('span')
label.textContent = button.getAttribute('aria-label')
button.append(label)
button.style.width = 'auto'
button.style.padding = '0 10px'
}
return root
}

const popovers = useCustomPopovers
? { previewHyperlink: byoPreviewHyperlink, createHyperlink: byoCreateHyperlink }
: noPopovers
? { previewHyperlink: null, createHyperlink: null }
: { previewHyperlink: previewHyperlinkPopover, createHyperlink: createHyperlinkPopover }
: {
previewHyperlink:
params.get('popover') === 'labels' ? labeledPreviewHyperlink : previewHyperlinkPopover,
createHyperlink: createHyperlinkPopover
}

const editor = new Editor({
element,
Expand Down
14 changes: 13 additions & 1 deletion extensions/extension-hypermultimedia/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,27 @@ here. Entries from 2.0.0 onward follow
historical Conventional Commits format. The project adheres to
[Semantic Versioning](https://semver.org/).

## [Unreleased]
## [2.1.0] — 2026-09-17

### Highlights

- Unlisted Vimeo links keep their access hashes through insertion and HTML round-trips.
- Hosts can import `isSafeMediaSrc` to validate media URLs before insertion.
- X fallback markup keeps the configured post options when oEmbed fails.

### Added

- Export `isSafeMediaSrc` from the package entry. Markdown import uses it to
refuse an unsafe URL before it mints a media node.

### Changed

- Require `@tiptap/core` and `@tiptap/pm` `^3.31.3`, and update the `@floating-ui/dom` runtime dependency to `^1.8.0`.

### Fixed

- Keep X theme, width, language, privacy, alignment, and hidden-content options in fallback markup after an oEmbed failure.

- Preserve the path hash in unlisted Vimeo URLs through insertion and HTML round-trips.
Query-string hashes keep precedence. Addresses [the standalone contribution](https://github.com/HMarzban/extension-hypermultimedia/pull/4).

Expand Down
17 changes: 17 additions & 0 deletions extensions/extension-hypermultimedia/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,19 @@ The media toolbar, the paste path, the insert commands and the caption each carr
- **A caption survives HTML round-trip for `image` only.** Every other node keeps the editable caption and the attribute, but emits no `<figure>`, so clipboard copy and the toolbar Copy action drop the text. Markdown export drops every caption. See [Caption](#caption).
- **The paste handler drops pasted image files silently without a listener.** Add an `editorFileUpload` listener and insert the nodes yourself — see [Image file paste (`editorFileUpload`)](#image-file-paste-editorfileupload).

### React Native and WebView hosts

The package runs inside a browser document. It does not provide a React Native bridge.
Browser touch tests do not establish compatibility with TenTap or a specific Android or iOS WebView.

Use the current node names and commands from [Migrating from 1.x](#migrating-from-1x).
Load `styles.css` inside the WebView document.
For X, configure `X`, call `setX`, and size posts with `maxwidth`; X has no resize gripper.
Provider and video taps keep their player controls, so expose a host action to open editing controls on touch devices.

When reporting a bridge problem, include the TenTap, WebView, Tiptap, and extension versions, device OS, editor HTML, bridge setup, and failing action.
Use a minimal runnable app. A viewport resize or browser touch simulation cannot replace a device reproduction.

## Styling

The package ships one stylesheet. It carries the resize gripper, the loading shell, the media toolbar, the caption, and the `x`, `loom` and `spotify` embed styles.
Expand Down Expand Up @@ -441,6 +454,10 @@ Provider embeds resolve options in two layers. Kit defaults come from `HyperMult

**X** sizes through the oEmbed `maxwidth` presets Compact `280`, Standard `400` and Wide `550`. The toolbar Post options menu switches both `maxwidth` and `theme`. `maxwidth` defaults to `400`. `theme` defaults to `'light'`, `lang` to `'en'`, `hide_media` and `hide_thread` to `false`. An X post reads its own `theme` attribute, not the page `color-scheme` — see [Theming](#theming). `dnt` defaults to `true` and is a kit option only, because `AddXOptions` omits it. The `align` attribute is a schema attribute passed straight to oEmbed, with no kit option and no `setX` field. The `x` node has no drag-resize.

When X's oEmbed endpoint fails, fallback markup keeps the configured widget options.
If `widgets.js` also fails, a canonical post link stays visible.
Keep browser CORS protection enabled; see [X network failures](https://github.com/docs-plus/docs.plus/tree/main/extensions/extension-hypermultimedia/src/nodes/x#network-failures).

## Media toolbar

Hovering a media node on a fine-pointer device opens the media toolbar at the node's top-right corner. Common actions sit inline, and the rest live behind a `…` overflow menu. Icon-only buttons show a floating tooltip on hover or focus.
Expand Down
4 changes: 4 additions & 0 deletions extensions/extension-hypermultimedia/cypress/e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ Clean-room Cypress suite against `test/playground/main.ts` via `@docs.plus/playg

## Supporting specs

Reported integrations also run through `nodes/text-focus.cy.ts`, `serialization/linked-image.cy.ts`, and `lifecycle/x-oembed-fallback.cy.ts`.
These cover text focus, inline image links, and visible X fallback markup with its configured widget options.
Browser checks do not verify React Native or TenTap on a device.

| Area | Specs |
| ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Nodes | `nodes/insert-nodes.cy.ts`, `nodes/nested-context.cy.ts` (gripper + toolbar stay aligned to media inside blockquote / list items) |
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
const STATUS_URL = 'https://x.com/jack/status/20'
const WRAPPER = '#editor .hypermultimedia--x__content'

describe('X oEmbed failure', () => {
beforeEach(() => {
cy.intercept('GET', 'https://publish.x.com/oembed*', { forceNetworkError: true }).as('oembed')
cy.intercept('GET', 'https://platform.twitter.com/widgets.js', { forceNetworkError: true })
cy.visitPlayground('xOptions=custom')
cy.setEditorContent('<p></p>')
})

for (const entry of ['command', 'paste'] as const) {
it(`keeps a visible link and widget options after ${entry} when both X endpoints fail`, () => {
if (entry === 'command') {
cy.getEditor().then((editor) => editor.commands.setX({ src: STATUS_URL }))
} else {
cy.get('#editor .ProseMirror').focus()
cy.pastePlainText(STATUS_URL)
}

cy.wait('@oembed')
cy.expectMediaLoadingReady()
cy.get(`${WRAPPER} .hm-media-slot`).should('have.css', 'opacity', '1')
cy.get(`${WRAPPER} blockquote.twitter-tweet`).should(($quote) => {
expect($quote.attr('data-theme')).to.equal('dark')
expect($quote.attr('data-width')).to.equal('400')
expect($quote.attr('data-lang')).to.equal('fr')
expect($quote.attr('data-dnt')).to.equal('false')
expect($quote.attr('data-cards')).to.equal('hidden')
expect($quote.attr('data-conversation')).to.equal('none')
})
cy.get(`${WRAPPER} blockquote a`)
.should('be.visible')
.and('have.attr', 'href', STATUS_URL)
.and('have.text', STATUS_URL)
})
}
})

export {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
describe('text focus beside media', () => {
it('keeps paragraph and heading clicks editable when the image extension is active', () => {
cy.visitPlayground()
cy.setEditorContent('<p>Paragraph</p><h2>Heading</h2>')
cy.getEditor().then((editor) => {
editor.commands.focus('end')
editor.commands.setImage({ src: 'https://example.com/photo.png', width: 200, height: 150 })
})

for (const selector of ['p', 'h2']) {
cy.get(`#editor ${selector}`).first().realClick({ position: 'left' })
cy.get('#editor .ProseMirror').should('have.focus')
cy.get(`#editor ${selector}`)
.first()
.then(($block) => {
const target = $block[0]
const rect = target.getBoundingClientRect()
const win = target.ownerDocument.defaultView!
target.dispatchEvent(
new win.MouseEvent('click', {
bubbles: true,
cancelable: true,
clientX: rect.left + 4,
clientY: rect.top + rect.height / 2
})
)
})
cy.get('#editor .ProseMirror').should('have.focus')
cy.realType('typed ')
cy.get(`#editor ${selector}`).first().should('contain.text', 'typed ')
}
cy.nodeCount('image').should('eq', 1)
})
})

export {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type {} from '@docs.plus/extension-hyperlink'

const IMAGE_URL = 'https://example.com/photo.png'

describe('hyperlink marks on inline images', () => {
it('preserves the image through link creation, URL editing, HTML round-trip, and unlinking', () => {
cy.visitPlayground('inlineImage=true')
cy.setEditorContent(`<p><img src="${IMAGE_URL}" alt="Example"></p>`)
cy.getEditor().then((editor) => {
editor.chain().setNodeSelection(1).setHyperlink({ href: 'https://example.com' }).run()
})
cy.get('#editor a img').should('have.attr', 'src', IMAGE_URL)

cy.getEditor().then((editor) => {
editor
.chain()
.setNodeSelection(1)
.extendMarkRange('hyperlink')
.editHyperlink({ newURL: 'https://changed.example' })
.run()
editor.commands.setContent(editor.getHTML())
})
cy.get('#editor a').should('have.attr', 'href', 'https://changed.example')
cy.get('#editor a img').should('have.attr', 'src', IMAGE_URL)

cy.getEditor().then((editor) => {
editor.chain().setNodeSelection(1).unsetHyperlink().run()
})
cy.get('#editor a').should('not.exist')
cy.get('#editor img').should('have.attr', 'src', IMAGE_URL)
})
})

export {}
2 changes: 1 addition & 1 deletion extensions/extension-hypermultimedia/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@docs.plus/extension-hypermultimedia",
"version": "2.0.0",
"version": "2.1.0",
"description": "Tiptap extension for embedded multimedia: images, audio, video, YouTube, Vimeo, SoundCloud, Spotify, Loom, and X — with resize, captions, and a media toolbar",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
Expand Down
Loading
Loading