diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2fade5df3..70784b40c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -130,6 +130,10 @@ As you can see to test you can repeat steps 1-3 as many times as you want. - Lint your code with `npm run lint`. - Format your code with `npm run format`. - Update the CHANGELOG.md to reflect the changes you made, we follow [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/). + - Add your entry under `## [Unreleased]`, in the `### Added` / `### Changed` / `### Fixed` section that matches your change (create the section if it isn't there yet). + - Format: `- [#ISSUE_ID](https://github.com/InditexTech/weavejs/issues/ISSUE_ID) ISSUE_NAME`, where `ISSUE_NAME` is the **verbatim title of the GitHub issue** (drop a generic prefix like `Feature request:`/`Bug:` if the issue has one, but otherwise don't paraphrase or summarize it). + - Example: `- [#1158](https://github.com/InditexTech/weavejs/issues/1158) Opt-in "fully enclosed" (contains) mode for drag-selection` + - On the PR, depending on your change add one of the following labels: - `skip-release`: when this PR is merged no release will be performed. diff --git a/code/CHANGELOG.md b/code/CHANGELOG.md index 98823f69e..6d8743be8 100644 --- a/code/CHANGELOG.md +++ b/code/CHANGELOG.md @@ -13,6 +13,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- [#1167](https://github.com/InditexTech/weavejs/issues/1167) Support hyperlinks on text nodes: make the whole text clickable to a defined URL + ## [5.3.0] - 2026-09-07 ### Added diff --git a/code/packages/sdk/src/nodes/extensions.d.ts b/code/packages/sdk/src/nodes/extensions.d.ts index 771e936e8..a1743f070 100644 --- a/code/packages/sdk/src/nodes/extensions.d.ts +++ b/code/packages/sdk/src/nodes/extensions.d.ts @@ -44,6 +44,7 @@ declare module 'konva/lib/Node' { closeCrop(type: WeaveImageCropEndType): void; resetCrop(): void; dblClick(): void; + click(payload: { wasSelected: boolean; ctrlOrMetaPressed: boolean }): void; allowedAnchors(): string[]; isSelectable(): boolean; handleMouseover(e: KonvaEventObject): void; diff --git a/code/packages/sdk/src/nodes/node.ts b/code/packages/sdk/src/nodes/node.ts index a56295e0e..28f546aba 100644 --- a/code/packages/sdk/src/nodes/node.ts +++ b/code/packages/sdk/src/nodes/node.ts @@ -58,6 +58,7 @@ export const augmentKonvaNodeClass = ( Konva.Node.prototype.closeCrop = function () {}; Konva.Node.prototype.resetCrop = function () {}; Konva.Node.prototype.dblClick = function () {}; + Konva.Node.prototype.click = function () {}; Konva.Node.prototype.allowedAnchors = function () { return []; }; diff --git a/code/packages/sdk/src/nodes/text/__tests__/text.test.ts b/code/packages/sdk/src/nodes/text/__tests__/text.test.ts index c475aa064..ce30f221d 100644 --- a/code/packages/sdk/src/nodes/text/__tests__/text.test.ts +++ b/code/packages/sdk/src/nodes/text/__tests__/text.test.ts @@ -6,7 +6,7 @@ import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from 'vitest'; import Konva from 'konva'; -import { WeaveTextNode } from '../text'; +import { WeaveTextNode, isValidTextLink } from '../text'; import { TEXT_LAYOUT, WEAVE_STAGE_TEXT_EDITION_MODE, @@ -304,6 +304,50 @@ describe('WeaveTextNode', () => { const text = node.onRender(defaultProps()) as Konva.Text; expect(typeof text.getAttr('measureMultilineText')).toBe('function'); }); + + it('3.6 link set — forces underline + link default colour, ignoring stored fill/textDecoration', () => { + const { node } = makeNode({ + link: { defaultColor: '#1155ccff', hoverColor: '#3d7be0ff' }, + }); + const text = node.onRender( + defaultProps({ link: 'https://example.com', fill: '#ff0000', textDecoration: 'line-through' }) + ) as Konva.Text; + expect(text.textDecoration()).toBe('underline'); + expect(text.fill()).toBe('#1155ccff'); + }); + + it('3.7 no link — fill/textDecoration come straight from props', () => { + const { node } = makeNode(); + const text = node.onRender( + defaultProps({ fill: '#ff0000', textDecoration: 'line-through' }) + ) as Konva.Text; + expect(text.textDecoration()).toBe('line-through'); + expect(text.fill()).toBe('#ff0000'); + }); + + it('3.8 link set for the first time — captures linkPreviousFill/linkPreviousTextDecoration from props', () => { + const { node } = makeNode(); + const text = node.onRender( + defaultProps({ link: 'https://example.com', fill: '#ff0000', textDecoration: 'line-through' }) + ) as Konva.Text; + expect(text.getAttr('linkPreviousFill')).toBe('#ff0000'); + expect(text.getAttr('linkPreviousTextDecoration')).toBe('line-through'); + }); + + it('3.9 link set, capture already present in props (e.g. loaded from persisted state) — keeps it, does not recapture the (already overridden) fill', () => { + const { node } = makeNode(); + const text = node.onRender( + defaultProps({ + link: 'https://example.com', + fill: '#1155ccff', // already the link's forced colour + textDecoration: 'underline', + linkPreviousFill: '#ff0000', // the real original, captured earlier + linkPreviousTextDecoration: 'line-through', + }) + ) as Konva.Text; + expect(text.getAttr('linkPreviousFill')).toBe('#ff0000'); + expect(text.getAttr('linkPreviousTextDecoration')).toBe('line-through'); + }); }); // --------------------------------------------------------------------------- @@ -832,6 +876,101 @@ describe('WeaveTextNode', () => { })); }); + it('12.2b link present — forces underline + link default colour', () => { + const { node } = makeNode({ + link: { defaultColor: '#1155ccff', hoverColor: '#3d7be0ff' }, + }); + const nodeInstance = new Konva.Text({ id: 'text-1' }); + const setAttrsSpy = vi.spyOn(nodeInstance, 'setAttrs'); + node.onUpdate(nodeInstance, defaultProps({ link: 'https://example.com', fill: '#ff0000' })); + expect(setAttrsSpy).toHaveBeenCalledWith(expect.objectContaining({ + textDecoration: 'underline', + fill: '#1155ccff', + })); + }); + + it('12.2c link removed — original fill/textDecoration from props applied again', () => { + const { node } = makeNode(); + const nodeInstance = new Konva.Text({ id: 'text-1' }); + const setAttrsSpy = vi.spyOn(nodeInstance, 'setAttrs'); + node.onUpdate(nodeInstance, defaultProps({ fill: '#ff0000', textDecoration: 'line-through' })); + expect(setAttrsSpy).toHaveBeenCalledWith(expect.objectContaining({ + textDecoration: 'line-through', + fill: '#ff0000', + })); + }); + + it('12.2d link set for the first time — captures linkPreviousFill/linkPreviousTextDecoration from nextProps', () => { + const { node } = makeNode(); + const nodeInstance = new Konva.Text({ id: 'text-1' }); + const setAttrsSpy = vi.spyOn(nodeInstance, 'setAttrs'); + node.onUpdate( + nodeInstance, + defaultProps({ link: 'https://example.com', fill: '#ff0000', textDecoration: 'line-through' }) + ); + expect(setAttrsSpy).toHaveBeenCalledWith(expect.objectContaining({ + linkPreviousFill: '#ff0000', + linkPreviousTextDecoration: 'line-through', + })); + }); + + it('12.2e link already had a capture — keeps it, does not recapture the (already overridden) live fill', () => { + const { node } = makeNode(); + const nodeInstance = new Konva.Text({ id: 'text-1', fill: '#1155ccff', textDecoration: 'underline' }); + const setAttrsSpy = vi.spyOn(nodeInstance, 'setAttrs'); + node.onUpdate( + nodeInstance, + defaultProps({ + link: 'https://example.com', + fill: '#1155ccff', + textDecoration: 'underline', + linkPreviousFill: '#ff0000', + linkPreviousTextDecoration: 'line-through', + }) + ); + expect(setAttrsSpy).toHaveBeenCalledWith(expect.objectContaining({ + linkPreviousFill: '#ff0000', + linkPreviousTextDecoration: 'line-through', + })); + }); + + it('12.2f full lifecycle — set link, re-serialize mid-way (simulating a resize/drag), then remove: true original survives throughout', () => { + const { node } = makeNode({ + link: { defaultColor: '#1155ccff', hoverColor: '#3d7be0ff' }, + }); + const nodeInstance = new Konva.Text({ id: 'text-1', fill: '#ff0000', textDecoration: 'line-through' }); + + // 1. Link gets set (e.g. via setLink()/a direct updateNode()) — state + // now carries the link plus the true original fill/textDecoration. + let state = defaultProps({ + link: 'https://example.com', + fill: '#ff0000', + textDecoration: 'line-through', + }); + node.onUpdate(nodeInstance, state); + expect(nodeInstance.getAttr('linkPreviousFill')).toBe('#ff0000'); + + // 2. Something re-serializes the node while linked (a resize/drag/ + // edit-exit all do this) — must NOT persist the live override. + const midway = node.serialize(nodeInstance); + expect(midway.props.fill).toBe('#ff0000'); + expect(midway.props.textDecoration).toBe('line-through'); + expect(midway.props.link).toBe('https://example.com'); + + // 3. State round-trips back through onUpdate (as it would after the + // updateNode() call above reaches this same client). + state = midway.props; + node.onUpdate(nodeInstance, state); + + // 4. Link finally gets removed. + const afterRemove = node.serialize(nodeInstance); + delete afterRemove.props.link; + delete afterRemove.props.linkPreviousFill; + delete afterRemove.props.linkPreviousTextDecoration; + expect(afterRemove.props.fill).toBe('#ff0000'); + expect(afterRemove.props.textDecoration).toBe('line-through'); + }); + it('12.3 layout=AUTO_ALL — computes width/height from textRenderedSize', () => { const { node, mock } = makeNode(); mock._stage.scaleX.mockReturnValue(1); @@ -1010,6 +1149,65 @@ describe('WeaveTextNode', () => { expect(result.props.isCloned).toBeUndefined(); expect(result.props.isCloneOrigin).toBeUndefined(); }); + + // Regression coverage: onRender()/onUpdate() apply the link's forced + // fill/textDecoration directly onto the live Konva node's own attrs (the + // same attrs getAttrs()/serialize() read) — so without this restoration, + // ANY serialize() call on a linked node (a resize, a drag, exiting text + // edit mode, ...) would read back the override and persist it as if it + // were the real value, permanently losing the user's original style the + // very first time any such interaction happened while linked. + it('13.6 link present with linkPreviousFill/linkPreviousTextDecoration captured — restores the true values, not the live (overridden) ones', () => { + const { node } = makeNode(); + const instance = new Konva.Text({ + id: 'text-abc', + nodeType: 'text', + link: 'https://example.com', + // What the live node currently looks like — i.e. what onRender()/ + // onUpdate() forced while the link is active. + fill: '#1155ccff', + textDecoration: 'underline', + // What onRender()/onUpdate() captured as the true original. + linkPreviousFill: '#ff0000', + linkPreviousTextDecoration: 'line-through', + }); + const result = node.serialize(instance); + expect(result.props.fill).toBe('#ff0000'); + expect(result.props.textDecoration).toBe('line-through'); + // The bookkeeping itself is preserved (not stripped) so it keeps + // surviving further serialize() calls until removeLink() clears it. + expect(result.props.linkPreviousFill).toBe('#ff0000'); + expect(result.props.linkPreviousTextDecoration).toBe('line-through'); + }); + + it('13.7 no link — fill/textDecoration passed through untouched even if stale linkPrevious* attrs linger', () => { + const { node } = makeNode(); + const instance = new Konva.Text({ + id: 'text-abc', + nodeType: 'text', + fill: '#00ff00', + textDecoration: '', + linkPreviousFill: '#ff0000', + linkPreviousTextDecoration: 'line-through', + }); + const result = node.serialize(instance); + expect(result.props.fill).toBe('#00ff00'); + expect(result.props.textDecoration).toBe(''); + }); + + it('13.8 link present but no capture yet — fill/textDecoration pass through as-is (nothing to restore from)', () => { + const { node } = makeNode(); + const instance = new Konva.Text({ + id: 'text-abc', + nodeType: 'text', + link: 'https://example.com', + fill: '#1155ccff', + textDecoration: 'underline', + }); + const result = node.serialize(instance); + expect(result.props.fill).toBe('#1155ccff'); + expect(result.props.textDecoration).toBe('underline'); + }); }); // --------------------------------------------------------------------------- @@ -1747,6 +1945,15 @@ describe('WeaveTextNode', () => { expect(withStroke.props.stroke).toBe('#abc'); expect(withStroke.props.strokeWidth).toBe(3); }); + + it('25.4 link included only when provided', () => { + const base = WeaveTextNode.defaultState('n-1'); + const withLink = WeaveTextNode.addNodeState(base, defaultProps({ link: 'https://example.com' })); + expect(withLink.props.link).toBe('https://example.com'); + + const withoutLink = WeaveTextNode.addNodeState(base, defaultProps()); + expect(withoutLink.props.link).toBeUndefined(); + }); }); // --------------------------------------------------------------------------- @@ -1766,6 +1973,12 @@ describe('WeaveTextNode', () => { const withHeight = WeaveTextNode.updateNodeState(base, defaultProps({ height: 200 })); expect(withHeight.props.height).toBe(200); }); + + it('26.3 link included only when provided', () => { + const base = WeaveTextNode.defaultState('n-1'); + const withLink = WeaveTextNode.updateNodeState(base, defaultProps({ link: 'https://example.com' })); + expect(withLink.props.link).toBe('https://example.com'); + }); }); // --------------------------------------------------------------------------- @@ -1849,6 +2062,36 @@ describe('WeaveTextNode', () => { const result = schema.safeParse(validPayload({ fontStyle: '700' })); expect(result.success).toBe(true); }); + + it('27.9 link omitted — passes (plain text node)', () => { + const schema = WeaveTextNode.getSchema(); + const result = schema.safeParse(validPayload()); + expect(result.success).toBe(true); + }); + + it('27.10 valid https link — passes', () => { + const schema = WeaveTextNode.getSchema(); + const result = schema.safeParse(validPayload({ link: 'https://example.com/page' })); + expect(result.success).toBe(true); + }); + + it('27.11 valid http link — passes', () => { + const schema = WeaveTextNode.getSchema(); + const result = schema.safeParse(validPayload({ link: 'http://example.com' })); + expect(result.success).toBe(true); + }); + + it('27.12 javascript: scheme — rejected', () => { + const schema = WeaveTextNode.getSchema(); + const result = schema.safeParse(validPayload({ link: 'javascript:alert(1)' })); + expect(result.success).toBe(false); + }); + + it('27.13 not a URL at all — rejected', () => { + const schema = WeaveTextNode.getSchema(); + const result = schema.safeParse(validPayload({ link: 'not a url' })); + expect(result.success).toBe(false); + }); }); // --------------------------------------------------------------------------- @@ -2162,4 +2405,305 @@ describe('WeaveTextNode', () => { expect(visibleSpy).not.toHaveBeenCalledWith(false); }); }); + + // --------------------------------------------------------------------------- + // Suite 33 — link hover colour + cursor + // --------------------------------------------------------------------------- + + describe('setupLinkBehavior() — hover colour + cursor', () => { + it('33.1 handleMouseover — link set — swaps fill to hoverColor', () => { + const { node } = makeNode({ + link: { defaultColor: '#1155ccff', hoverColor: '#3d7be0ff' }, + }); + const text = node.onRender(defaultProps({ link: 'https://example.com' })) as Konva.Text; + text.handleMouseover?.({} as never); + expect(text.fill()).toBe('#3d7be0ff'); + }); + + it('33.2 handleMouseover — no link — fill untouched', () => { + const { node } = makeNode({ + link: { defaultColor: '#1155ccff', hoverColor: '#3d7be0ff' }, + }); + const text = node.onRender(defaultProps({ fill: '#ff0000' })) as Konva.Text; + text.handleMouseover?.({} as never); + expect(text.fill()).toBe('#ff0000'); + }); + + it('33.3 handleMouseout — link set — reverts fill to link defaultColor', () => { + const { node } = makeNode({ + link: { defaultColor: '#1155ccff', hoverColor: '#3d7be0ff' }, + }); + const text = node.onRender(defaultProps({ link: 'https://example.com' })) as Konva.Text; + text.fill('#3d7be0ff'); + text.handleMouseout?.({} as never); + expect(text.fill()).toBe('#1155ccff'); + }); + + it('33.4 defineMousePointer — pointer when link set, default otherwise', () => { + const { node } = makeNode(); + const linked = node.onRender(defaultProps({ link: 'https://example.com' })) as Konva.Text; + const plain = node.onRender(defaultProps({ id: 'text-2' })) as Konva.Text; + expect(linked.defineMousePointer?.()).toBe('pointer'); + expect(plain.defineMousePointer?.()).toBe('default'); + }); + }); + + // --------------------------------------------------------------------------- + // Suite 34 — click-to-open (text.click() property hook) + // --------------------------------------------------------------------------- + + // Regression coverage for: once a node is selected, its hit area is covered + // by the Transformer's own overdraw shape (used to drag-move the whole + // selection), so a `text.on('pointerclick', ...)` Konva event listener + // registered directly on the node would silently stop firing the moment + // the node becomes selected — i.e. exactly the "click on an already + // selected linked text node" case this feature depends on. That's why + // click-to-open is wired through the `click()`/`dblClick()` property hooks + // (invoked directly by click-tap.ts on the resolved real node, the same + // way double-click-to-edit already works regardless of selection state) + // instead of a raw Konva event — these tests call those hooks directly, + // the same way click-tap.ts does, rather than simulating Konva events. + describe('setupLinkBehavior() — click to open link', () => { + it('34.1 no link — click() is a no-op even with Ctrl held', () => { + const { node } = makeNode(); + const text = node.onRender(defaultProps()) as Konva.Text; + const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null); + text.click({ wasSelected: false, ctrlOrMetaPressed: true }); + expect(openSpy).not.toHaveBeenCalled(); + openSpy.mockRestore(); + }); + + it('34.2 Ctrl/Cmd+Click — opens immediately, no prior selection required', () => { + const { node } = makeNode(); + const text = node.onRender(defaultProps({ link: 'https://example.com' })) as Konva.Text; + const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null); + text.click({ wasSelected: false, ctrlOrMetaPressed: true }); + expect(openSpy).toHaveBeenCalledWith('https://example.com', '_blank', 'noopener,noreferrer'); + openSpy.mockRestore(); + }); + + it('34.3 plain click, node not selected before the gesture — does not open', () => { + const { node } = makeNode(); + const text = node.onRender(defaultProps({ link: 'https://example.com' })) as Konva.Text; + const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null); + text.click({ wasSelected: false, ctrlOrMetaPressed: false }); + expect(openSpy).not.toHaveBeenCalled(); + openSpy.mockRestore(); + }); + + it('34.4 plain click, node already selected before the gesture — opens after the double-click guard window', async () => { + const { node } = makeNode(); + const text = node.onRender(defaultProps({ link: 'https://example.com' })) as Konva.Text; + const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null); + text.click({ wasSelected: true, ctrlOrMetaPressed: false }); + expect(openSpy).not.toHaveBeenCalled(); // not yet — debounced + await new Promise((resolve) => setTimeout(resolve, Konva.dblClickWindow + 30)); + expect(openSpy).toHaveBeenCalledWith('https://example.com', '_blank', 'noopener,noreferrer'); + openSpy.mockRestore(); + }); + + it('34.5 a dblClick() within the guard window cancels the pending open', async () => { + const { node } = makeNode(); + const text = node.onRender(defaultProps({ link: 'https://example.com' })) as Konva.Text; + const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null); + text.click({ wasSelected: true, ctrlOrMetaPressed: false }); + text.dblClick(); + await new Promise((resolve) => setTimeout(resolve, Konva.dblClickWindow + 30)); + expect(openSpy).not.toHaveBeenCalled(); + openSpy.mockRestore(); + }); + + it('34.6 dblClick() still triggers edit mode as before (wrapping preserves the original behaviour)', () => { + const textNode = new Konva.Text({ id: 'text-1', nodeType: WEAVE_TEXT_NODE_TYPE }); + const plugin = makePluginMock([textNode]); + const { node, mock } = makeNode(); + mock.getPlugin.mockImplementation((name: string) => { + if (name === 'nodesMultiSelectionFeedback') return mock._feedbackPlugin; + if (name === 'nodesSelection') return mock._selectionPlugin; + if (name === 'usersPresence') return mock._presencePlugin; + return plugin; + }); + mock.getActiveAction.mockReturnValue('selectionTool'); + const stageContainer = document.createElement('div'); + document.body.appendChild(stageContainer); + mock._stage.container.mockReturnValue(stageContainer); + const text = node.onRender(defaultProps({ link: 'https://example.com' })) as Konva.Text; + text.setAttr('id', 'text-1'); + mock._selectionPlugin.getSelectedNodes.mockReturnValue([text]); + text.dblClick(); + expect(mock.setMutexLock).toHaveBeenCalled(); + }); + + it('34.7 server-side — never calls window.open', () => { + const { node, mock } = makeNode(); + mock.isServerSide.mockReturnValue(true); + const text = node.onRender(defaultProps({ link: 'https://example.com' })) as Konva.Text; + const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null); + text.click({ wasSelected: false, ctrlOrMetaPressed: true }); + expect(openSpy).not.toHaveBeenCalled(); + openSpy.mockRestore(); + }); + + it('34.8 non-http(s) link stored on the node — defensively rejected at click-time', () => { + const { node } = makeNode(); + const text = node.onRender(defaultProps()) as Konva.Text; + text.setAttr('link', 'javascript:alert(1)'); + const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null); + text.click({ wasSelected: false, ctrlOrMetaPressed: true }); + expect(openSpy).not.toHaveBeenCalled(); + openSpy.mockRestore(); + }); + }); + + // --------------------------------------------------------------------------- + // Suite 35 — public API: getLink / setLink / removeLink + // --------------------------------------------------------------------------- + + describe('getLink() / setLink() / removeLink()', () => { + it('35.1 getLink — undefined when no link set', () => { + const { node } = makeNode(); + const text = new Konva.Text({ id: 'text-1', nodeType: WEAVE_TEXT_NODE_TYPE }); + expect(node.getLink(text)).toBeUndefined(); + }); + + it('35.2 getLink — returns the stored URL', () => { + const { node } = makeNode(); + const text = new Konva.Text({ id: 'text-1', nodeType: WEAVE_TEXT_NODE_TYPE, link: 'https://example.com' }); + expect(node.getLink(text)).toBe('https://example.com'); + }); + + it('35.3 setLink — valid URL — updateNode called with link in props, rest of attrs preserved', () => { + const { node, mock } = makeNode(); + const text = new Konva.Text({ id: 'text-1', nodeType: WEAVE_TEXT_NODE_TYPE, fill: '#ff0000' }); + node.setLink(text, 'https://example.com'); + expect(mock.updateNode).toHaveBeenCalledWith( + expect.objectContaining({ + key: 'text-1', + props: expect.objectContaining({ link: 'https://example.com', fill: '#ff0000' }), + }) + ); + }); + + it('35.4 setLink — invalid URL — updateNode not called', () => { + const { node, mock } = makeNode(); + const text = new Konva.Text({ id: 'text-1', nodeType: WEAVE_TEXT_NODE_TYPE }); + node.setLink(text, 'javascript:alert(1)'); + expect(mock.updateNode).not.toHaveBeenCalled(); + }); + + it('35.5 removeLink — updateNode called with link key omitted', () => { + const { node, mock } = makeNode(); + const text = new Konva.Text({ id: 'text-1', nodeType: WEAVE_TEXT_NODE_TYPE, link: 'https://example.com' }); + node.removeLink(text); + const call = mock.updateNode.mock.calls[0][0]; + expect('link' in call.props).toBe(false); + }); + + it('35.6 setLink — captures linkPreviousFill/linkPreviousTextDecoration in the same updateNode call', () => { + const { node, mock } = makeNode(); + const text = new Konva.Text({ + id: 'text-1', + nodeType: WEAVE_TEXT_NODE_TYPE, + fill: '#ff0000', + textDecoration: 'line-through', + }); + node.setLink(text, 'https://example.com'); + expect(mock.updateNode).toHaveBeenCalledWith( + expect.objectContaining({ + props: expect.objectContaining({ + linkPreviousFill: '#ff0000', + linkPreviousTextDecoration: 'line-through', + }), + }) + ); + }); + + it('35.7 removeLink — also omits linkPreviousFill/linkPreviousTextDecoration (cleanup) and restores the true fill', () => { + const { node, mock } = makeNode(); + // Simulates a node that has been linked (and possibly re-serialized + // meanwhile) and now carries both the live override and the captured + // originals — exactly what onRender()/onUpdate() would have produced. + const text = new Konva.Text({ + id: 'text-1', + nodeType: WEAVE_TEXT_NODE_TYPE, + link: 'https://example.com', + fill: '#1155ccff', + textDecoration: 'underline', + linkPreviousFill: '#ff0000', + linkPreviousTextDecoration: 'line-through', + }); + node.removeLink(text); + const call = mock.updateNode.mock.calls[0][0]; + expect('link' in call.props).toBe(false); + expect('linkPreviousFill' in call.props).toBe(false); + expect('linkPreviousTextDecoration' in call.props).toBe(false); + expect(call.props.fill).toBe('#ff0000'); + expect(call.props.textDecoration).toBe('line-through'); + }); + + it('35.8 full lifecycle via the public API — setLink, simulate a re-render while linked, then removeLink: the true original fill/textDecoration survive', () => { + const { node, mock } = makeNode({ + link: { defaultColor: '#1155ccff', hoverColor: '#3d7be0ff' }, + }); + const text = new Konva.Text({ + id: 'text-1', + nodeType: WEAVE_TEXT_NODE_TYPE, + fill: '#ff0000', + textDecoration: 'line-through', + }); + + // 1. setLink() — captures the true original alongside the new link. + node.setLink(text, 'https://example.com'); + const afterSetLink = mock.updateNode.mock.calls[0][0]; + expect(afterSetLink.props.linkPreviousFill).toBe('#ff0000'); + + // 2. The resulting state update reaches onUpdate(), which forces the + // link styling onto the *live* node — exactly what would happen for + // every collaborator's rendered copy of this node. + node.onUpdate(text, afterSetLink.props); + expect(text.fill()).toBe('#1155ccff'); // now visually overridden + expect(text.getAttr('linkPreviousFill')).toBe('#ff0000'); // but remembered + + // 3. Some unrelated interaction re-serializes the node while still + // linked (a resize/drag/edit-exit) and pushes that through + // updateNode()/onUpdate() again, same as production code paths do. + const midway = node.serialize(text); + expect(midway.props.fill).toBe('#ff0000'); // NOT the live override + node.onUpdate(text, midway.props); + + // 4. removeLink() — the true original must come back, not the link + // colour that was sitting on the live node a moment ago. + node.removeLink(text); + const afterRemove = mock.updateNode.mock.calls[mock.updateNode.mock.calls.length - 1][0]; + expect(afterRemove.props.fill).toBe('#ff0000'); + expect(afterRemove.props.textDecoration).toBe('line-through'); + expect('link' in afterRemove.props).toBe(false); + }); + }); + + // --------------------------------------------------------------------------- + // Suite 36 — isValidTextLink() helper + // --------------------------------------------------------------------------- + + describe('isValidTextLink()', () => { + it('36.1 https URL — valid', () => { + expect(isValidTextLink('https://example.com')).toBe(true); + }); + + it('36.2 http URL — valid', () => { + expect(isValidTextLink('http://example.com/page?x=1')).toBe(true); + }); + + it('36.3 javascript: scheme — invalid', () => { + expect(isValidTextLink('javascript:alert(1)')).toBe(false); + }); + + it('36.4 malformed string — invalid', () => { + expect(isValidTextLink('not a url')).toBe(false); + }); + + it('36.5 ftp: scheme — invalid (not in the http(s) allowlist)', () => { + expect(isValidTextLink('ftp://example.com/file')).toBe(false); + }); + }); }); diff --git a/code/packages/sdk/src/nodes/text/constants.ts b/code/packages/sdk/src/nodes/text/constants.ts index 3a6fc315d..34a1a32e4 100644 --- a/code/packages/sdk/src/nodes/text/constants.ts +++ b/code/packages/sdk/src/nodes/text/constants.ts @@ -22,8 +22,18 @@ export const WEAVE_TEXT_NODE_DEFAULT_CONFIG: WeaveTextProperties = { edition: { borderSize: 2, }, + link: { + defaultColor: '#1155ccff', + hoverColor: '#3d7be0ff', + }, }; +// Schemes allowed for the `link` attribute of a text node. Anything else +// (javascript:, data:, vbscript:, etc.) is rejected both at the schema +// level and again defensively at click-time, since the URL is untrusted +// user-authored content that another collaborator will click on. +export const WEAVE_TEXT_LINK_ALLOWED_PROTOCOLS = ['http:', 'https:']; + export const TEXT_LAYOUT = { ['SMART']: 'smart', ['AUTO_ALL']: 'auto-all', diff --git a/code/packages/sdk/src/nodes/text/text.ts b/code/packages/sdk/src/nodes/text/text.ts index de4787e97..8804c74b6 100644 --- a/code/packages/sdk/src/nodes/text/text.ts +++ b/code/packages/sdk/src/nodes/text/text.ts @@ -20,6 +20,7 @@ import { import { TEXT_LAYOUT, WEAVE_STAGE_TEXT_EDITION_MODE, + WEAVE_TEXT_LINK_ALLOWED_PROTOCOLS, WEAVE_TEXT_NODE_DEFAULT_CONFIG, WEAVE_TEXT_NODE_TYPE, } from './constants'; @@ -33,6 +34,24 @@ import type { import merge from 'lodash/merge'; import { WEAVE_STAGE_DEFAULT_MODE } from '../stage/constants'; +/** + * Validates that a link is an absolute http(s) URL. Used both by the zod + * schema (getSchema()) and defensively again at click-time (setLink() and + * the pointerclick handler), since the value is user-authored content + * rendered and clicked on by other collaborators — schemes like + * `javascript:` or `data:` must never reach `window.open`. + */ +export function isValidTextLink(value: string): boolean { + try { + const url = new URL(value); + return (WEAVE_TEXT_LINK_ALLOWED_PROTOCOLS as string[]).includes( + url.protocol + ); + } catch { + return false; + } +} + export class WeaveTextNode extends WeaveNode { private config: WeaveTextProperties; protected nodeType: string = WEAVE_TEXT_NODE_TYPE; @@ -143,6 +162,25 @@ export class WeaveTextNode extends WeaveNode { } onRender(props: WeaveElementAttributes): WeaveElementInstance { + // A link forces the whole text underlined + the link colour. The *true* + // fill/textDecoration must survive that override so removing the link + // restores them — but they can't just be left alone in `props`: once + // applied below they become the live Konva node's own `fill`/ + // `textDecoration` attrs, which is exactly what serialize() reads via + // getAttrs() on every resize/drag/edit-exit. Without capturing them + // separately, the *first* such interaction while linked would + // read back the override and persist it as if it were the real value, + // permanently clobbering the user's original style. Capture once (the + // first render/update where `link` is truthy and no capture exists yet) + // into `linkPreviousFill`/`linkPreviousTextDecoration`; serialize() + // restores from those instead of trusting the live (overridden) attrs. + const linkPreviousFill = props.link + ? (props.linkPreviousFill ?? props.fill) + : props.linkPreviousFill; + const linkPreviousTextDecoration = props.link + ? (props.linkPreviousTextDecoration ?? props.textDecoration) + : props.linkPreviousTextDecoration; + const text = new Konva.Text({ ...props, name: 'node', @@ -155,6 +193,12 @@ export class WeaveTextNode extends WeaveNode { strokeWidth: this.config.outline.width, fillAfterStrokeEnabled: true, }), + ...(props.link && { + textDecoration: 'underline', + fill: this.config.link.defaultColor, + linkPreviousFill, + linkPreviousTextDecoration, + }), }); this.setupDefaultNodeAugmentation(text); @@ -249,6 +293,8 @@ export class WeaveTextNode extends WeaveNode { text.setAttr('triggerEditMode', this.triggerEditMode.bind(this)); + this.setupLinkBehavior(text); + let actualAnchor: string | null | undefined = undefined; text.on('transformstart', (e) => { @@ -422,6 +468,17 @@ export class WeaveTextNode extends WeaveNode { nodeInstance: WeaveElementInstance, nextProps: WeaveElementAttributes ): void { + // See onRender() for why linkPreviousFill/linkPreviousTextDecoration + // exist: they capture the true fill/textDecoration once, so serialize() + // can restore them instead of persisting the live (link-overridden) attrs. + const isLinked = Boolean(nextProps.link); + const linkPreviousFill = nextProps.link + ? (nextProps.linkPreviousFill ?? nextProps.fill) + : nextProps.linkPreviousFill; + const linkPreviousTextDecoration = nextProps.link + ? (nextProps.linkPreviousTextDecoration ?? nextProps.textDecoration) + : nextProps.linkPreviousTextDecoration; + nodeInstance.setAttrs({ ...nextProps, ...(!this.config.outline.enabled && { @@ -433,6 +490,27 @@ export class WeaveTextNode extends WeaveNode { strokeWidth: this.config.outline.width, fillAfterStrokeEnabled: true, }), + // Konva's setAttrs() only ever touches keys actually present on the + // object passed to it (see Konva.Node.setAttrs/_setAttr) — a key + // that's simply absent from `nextProps` (e.g. `link` right after + // removeLink() deletes it from state) is left completely untouched + // on the *live* node. Spreading `...nextProps` alone is therefore not + // enough to ever clear these three once they've been set: they must + // always be included explicitly (falling back to `undefined`, which + // Konva's _setAttr treats as "delete this attr") so removing a link + // actually clears the live node's `link`/linkPrevious* attrs instead + // of leaving them stuck — which otherwise keeps hover/click behaving + // as if the (removed) link were still active, and corrupts the next + // serialize() call for this node. + link: nextProps.link, + linkPreviousFill: isLinked ? linkPreviousFill : undefined, + linkPreviousTextDecoration: isLinked + ? linkPreviousTextDecoration + : undefined, + ...(isLinked && { + textDecoration: 'underline', + fill: this.config.link.defaultColor, + }), }); let width = nextProps.width; @@ -495,6 +573,20 @@ export class WeaveTextNode extends WeaveNode { delete cleanedAttrs.shouldUpdateOnTransform; delete cleanedAttrs.dragBoundFunc; + // The live node's own fill/textDecoration are the link's forced styling + // (see onRender()/onUpdate()) whenever a link is set, not the user's + // real values — restore the real ones captured there instead, so a + // resize/drag/edit-exit (anything that re-serializes this node) never + // persists the override in place of the true fill/textDecoration. + if (cleanedAttrs.link) { + if (typeof cleanedAttrs.linkPreviousFill === 'string') { + cleanedAttrs.fill = cleanedAttrs.linkPreviousFill; + } + if (typeof cleanedAttrs.linkPreviousTextDecoration === 'string') { + cleanedAttrs.textDecoration = cleanedAttrs.linkPreviousTextDecoration; + } + } + return { key: attrs.id ?? '', type: attrs.nodeType, @@ -1205,6 +1297,154 @@ export class WeaveTextNode extends WeaveNode { ); } + /** + * Wires the hover colour swap, link cursor and click-to-open behaviour for + * a rendered text node. Runs on top of (not instead of) the generic + * selection-hover-halo handling in setupDefaultNodeEvents()/node.ts, the + * same way connector.ts layers its own handleMouseover/handleMouseout. + */ + private setupLinkBehavior(text: Konva.Text): void { + text.handleMouseover = () => { + if (!text.getAttrs().link) { + return; + } + text.fill(this.config.link.hoverColor); + text.getLayer()?.batchDraw(); + }; + + text.handleMouseout = () => { + if (!text.getAttrs().link) { + return; + } + text.fill(this.config.link.defaultColor); + text.getLayer()?.batchDraw(); + }; + + text.defineMousePointer = () => { + return text.getAttrs().link ? 'pointer' : 'default'; + }; + + // Distinguishes "already selected when this click started" from "just + // got selected by this very click" — computed and passed in by + // click-tap.ts's `nodeTargeted.click({...})` call, not by listening for + // a raw Konva 'pointerclick' here: once a node is selected, its hit area + // is covered by the Transformer's own overdraw shape (used to drag-move + // the whole selection), so `text.on('pointerclick', ...)` would stop + // firing the moment the node becomes selected — exactly the case we + // need (open the link on a click on an *already selected* node). See + // click-tap.ts for how `nodeTargeted` is resolved through that overlay. + let linkClickTimeout: ReturnType | undefined; + const clearLinkClickTimeout = () => { + if (linkClickTimeout) { + clearTimeout(linkClickTimeout); + linkClickTimeout = undefined; + } + }; + + // A genuine double-click (which enters edit mode, see dblClick below) + // still goes through a "plain click" pass first — click-tap.ts's own + // gesture detector only confirms it's a double-tap on the second tap's + // pointerup, after that same tap's pointerdown already ran the single-click + // path once. Clear any pending link-open whenever a real double-click + // fires, so it's never misread as two link-opens or one premature one. + const previousDblClick = text.dblClick.bind(text); + text.dblClick = () => { + clearLinkClickTimeout(); + previousDblClick(); + }; + + text.click = ({ wasSelected, ctrlOrMetaPressed }) => { + if (!text.getAttrs().link) { + return; + } + + // Desktop shortcut: open immediately, no prior selection required. + // Ctrl/Cmd+Click is otherwise a no-op on nodes (see click-tap.ts), so + // this adds behaviour without changing any existing gesture. + if (ctrlOrMetaPressed) { + clearLinkClickTimeout(); + this.openTextLink(text); + return; + } + + // Universal (mouse + touch, no modifier needed): a click/tap on a + // node that was *already* selected opens the link. A first click on + // an unselected node still only selects it, same as every other + // node type. Debounced by Konva's own double-click window so a real + // double-click can cancel it (see dblClick above) instead of it firing + // mid-gesture. + if (wasSelected) { + clearLinkClickTimeout(); + linkClickTimeout = setTimeout(() => { + linkClickTimeout = undefined; + if (!this.editing) { + this.openTextLink(text); + } + }, Konva.dblClickWindow); + } + }; + } + + private openTextLink(textNode: Konva.Text): void { + if (this.instance.isServerSide()) { + return; + } + + const link = textNode.getAttrs().link; + if (typeof link !== 'string' || !isValidTextLink(link)) { + return; + } + + window.open(link, '_blank', 'noopener,noreferrer'); + } + + /** + * Public API to read/set/remove the URL a text node links to. Setting an + * invalid or non-http(s) URL is a no-op (see isValidTextLink()). + * + * The node's real fill/textDecoration are captured once into + * `linkPreviousFill`/`linkPreviousTextDecoration` the first time a link is + * rendered (see onRender()/onUpdate()) and restored by serialize() + * whenever the node is re-serialized while linked — so they survive any + * number of resizes/drags/edits while the link is active. removeLink() + * relies on that: it omits `link` *and* those two bookkeeping props from + * the serialized props entirely, so all three are deleted from shared + * state (see updateYjsMapFromObject in managers/state.ts) rather than + * persisted as `undefined`, and the node's original fill/text decoration + * reappear immediately. + */ + getLink(nodeInstance: WeaveElementInstance): string | undefined { + const link = nodeInstance.getAttrs().link; + return typeof link === 'string' ? link : undefined; + } + + setLink(nodeInstance: WeaveElementInstance, url: string): void { + if (!isValidTextLink(url)) { + return; + } + + // serialize() already restores props.fill/props.textDecoration to the + // true pre-link values when a link is already set (see serialize()), so + // this captures the right originals whether this call is adding a link + // for the first time or just changing an existing link's URL. + const serialized = this.serialize(nodeInstance); + serialized.props.linkPreviousFill = serialized.props.fill; + serialized.props.linkPreviousTextDecoration = serialized.props.textDecoration; + serialized.props.link = url; + this.instance.updateNode(serialized); + } + + removeLink(nodeInstance: WeaveElementInstance): void { + // serialize() already restored props.fill/props.textDecoration to the + // true pre-link values (see serialize()) — just drop the link and the + // bookkeeping props used to remember them. + const serialized = this.serialize(nodeInstance); + delete serialized.props.link; + delete serialized.props.linkPreviousFill; + delete serialized.props.linkPreviousTextDecoration; + this.instance.updateNode(serialized); + } + onDestroyInstance(): void { super.onDestroyInstance(); if (!this.instance.isServerSide() && this.keyPressHandler) { @@ -1294,6 +1534,7 @@ export class WeaveTextNode extends WeaveNode { ...(props.fillAfterStrokeEnabled && { fillAfterStrokeEnabled: props.fillAfterStrokeEnabled, }), + ...(props.link && { link: props.link }), }, }); } @@ -1329,6 +1570,7 @@ export class WeaveTextNode extends WeaveNode { ...(nextProps.fillAfterStrokeEnabled && { fillAfterStrokeEnabled: nextProps.fillAfterStrokeEnabled, }), + ...(nextProps.link && { link: nextProps.link }), }, }); } @@ -1416,6 +1658,39 @@ export class WeaveTextNode extends WeaveNode { .default('text') .describe('The actual text content of the node.'), + link: z + .string() + .url() + .refine((value) => isValidTextLink(value), { + message: 'Link must be an absolute http:// or https:// URL.', + }) + .optional() + .describe( + 'External URL the whole text of the node links to. When set, the ' + + 'text is forced underlined and coloured with the configured link ' + + 'colours regardless of fill/textDecoration, is clickable, and ' + + 'opens the URL in a new tab. Only http(s) URLs are accepted. ' + + 'Omit/remove to make the node plain text again.' + ), + linkPreviousFill: z + .string() + .optional() + .describe( + 'Internal bookkeeping: the fill colour the node had right ' + + 'before a link was set, captured automatically so it can be ' + + 'restored when the link is removed. Managed automatically — ' + + 'do not set directly.' + ), + linkPreviousTextDecoration: z + .string() + .optional() + .describe( + 'Internal bookkeeping: the textDecoration the node had right ' + + 'before a link was set, captured automatically so it can be ' + + 'restored when the link is removed. Managed automatically — ' + + 'do not set directly.' + ), + strokeEnabled: z .boolean() .default(false) diff --git a/code/packages/sdk/src/nodes/text/types.ts b/code/packages/sdk/src/nodes/text/types.ts index cf4d90f4a..4d58f03c9 100644 --- a/code/packages/sdk/src/nodes/text/types.ts +++ b/code/packages/sdk/src/nodes/text/types.ts @@ -34,11 +34,17 @@ export type WeaveTextOutlineProperties = enabled: false; }; +export type WeaveTextLinkProperties = { + defaultColor: string; + hoverColor: string; +}; + export type WeaveTextProperties = { transform: WeaveNodeTransformerProperties; outline: WeaveTextOutlineProperties; edition: WeaveTextEditionProperties; cursor: WeaveTextCursorProperties; + link: WeaveTextLinkProperties; }; export type WeaveTextNodeParams = { diff --git a/code/packages/sdk/src/plugins/nodes-selection/__tests__/events/click-tap.test.ts b/code/packages/sdk/src/plugins/nodes-selection/__tests__/events/click-tap.test.ts index 5ecb21e28..8b1fea9fe 100644 --- a/code/packages/sdk/src/plugins/nodes-selection/__tests__/events/click-tap.test.ts +++ b/code/packages/sdk/src/plugins/nodes-selection/__tests__/events/click-tap.test.ts @@ -66,6 +66,7 @@ function makeNode(attrs: Record = {}) { .mockReturnValue({ nodeType: 'rect', id: 'node-1', ...attrs }), getParent: vi.fn().mockReturnValue(null), dblClick: vi.fn(), + click: vi.fn(), handleSelectNode: vi.fn(), handleDeselectNode: vi.fn(), defineMousePointer: undefined as (() => string) | undefined, @@ -360,6 +361,72 @@ describe('handleClickOrTap', () => { expect(ctx.triggerSelectedNodesEvent).not.toHaveBeenCalled(); }); + // Regression coverage for the text-node hyperlink feature: `click()` must + // be invoked on the resolved node itself (not left to a raw Konva event on + // the node), because once a node is selected its hit area is covered by + // the Transformer's own overdraw shape — a `node.on('pointerclick', ...)` + // listener would stop firing the moment the node becomes selected, which + // is exactly the case a click-to-open-link feature depends on. + it('calls click({wasSelected, ctrlOrMetaPressed}) on plain click, not yet selected', () => { + const ctx = makeCtx(); + const node = makeNode(); + ctx.getWeaveInstance().getRealSelectedNode = vi.fn().mockReturnValue(node); + const e = makeEvent({}, node); + handleClickOrTap(ctx, e); + expect(node.click).toHaveBeenCalledWith({ + wasSelected: false, + ctrlOrMetaPressed: false, + }); + }); + + it('calls click({wasSelected: true, ...}) on plain click when the node was already selected', () => { + const ctx = makeCtx(); + const node = makeNode({ id: 'node-1' }); + ctx.getWeaveInstance().getRealSelectedNode = vi.fn().mockReturnValue(node); + ctx.getTransformerController().getTransformer = vi + .fn() + .mockReturnValue(makeTransformer([node])); + const e = makeEvent({}, node); + handleClickOrTap(ctx, e); + expect(node.click).toHaveBeenCalledWith({ + wasSelected: true, + ctrlOrMetaPressed: false, + }); + }); + + it('calls click({ctrlOrMetaPressed: true, ...}) on Ctrl/Cmd+Click, even though selection itself is a no-op', () => { + const ctx = makeCtx(); + const node = makeNode(); + ctx.getWeaveInstance().getRealSelectedNode = vi.fn().mockReturnValue(node); + const e = makeEvent({ ctrlKey: true }, node); + handleClickOrTap(ctx, e); + expect(node.click).toHaveBeenCalledWith({ + wasSelected: false, + ctrlOrMetaPressed: true, + }); + expect(ctx.triggerSelectedNodesEvent).not.toHaveBeenCalled(); + }); + + it('does not call click() on shift+click (multi-select gesture, not a per-node interaction)', () => { + const ctx = makeCtx(); + const node = makeNode({ id: 'node-1' }); + ctx.getWeaveInstance().getRealSelectedNode = vi.fn().mockReturnValue(node); + const e = makeEvent({ shiftKey: true }, node); + handleClickOrTap(ctx, e); + expect(node.click).not.toHaveBeenCalled(); + }); + + it('does not call click() on a double-tap (dblClick() handles it and returns early instead)', () => { + const ctx = makeCtx(); + const node = makeNode(); + (ctx.getGesture() as unknown as { isDoubleTap: boolean }).isDoubleTap = + true; + ctx.getWeaveInstance().getRealSelectedNode = vi.fn().mockReturnValue(node); + const e = makeEvent({}, node); + handleClickOrTap(ctx, e); + expect(node.click).not.toHaveBeenCalled(); + }); + it('single-selects the node when no meta key is held', () => { const ctx = makeCtx(); const node = makeNode(); @@ -549,10 +616,12 @@ describe('handleClickOrTap', () => { const outerGroupProxy = { getAttrs: vi.fn().mockReturnValue({ id: 'outer-group', nodeType: 'group' }), getParent: vi.fn().mockReturnValue(null), + click: vi.fn(), }; const innerGroupProxy = { getAttrs: vi.fn().mockReturnValue({ id: 'inner-group', nodeType: 'group' }), getParent: vi.fn().mockReturnValue(outerGroupProxy), + click: vi.fn(), }; innerNode.getParent = vi.fn().mockReturnValue(innerGroupProxy); outerGroup.getParent = vi.fn().mockReturnValue(null); @@ -626,8 +695,8 @@ describe('handleClickOrTap', () => { // leaf → bottomGroup → topGroup → null leaf.getParent = vi.fn().mockReturnValue({ getAttrs: vi.fn().mockReturnValue({ id: 'bottom-group', nodeType: 'group' }) }); - const bottomGroupParent = { getAttrs: vi.fn().mockReturnValue({ id: 'top-group', nodeType: 'group' }), getParent: vi.fn().mockReturnValue(null) }; - const leafParent = { getAttrs: vi.fn().mockReturnValue({ id: 'bottom-group', nodeType: 'group' }), getParent: vi.fn().mockReturnValue(bottomGroupParent) }; + const bottomGroupParent = { getAttrs: vi.fn().mockReturnValue({ id: 'top-group', nodeType: 'group' }), getParent: vi.fn().mockReturnValue(null), click: vi.fn() }; + const leafParent = { getAttrs: vi.fn().mockReturnValue({ id: 'bottom-group', nodeType: 'group' }), getParent: vi.fn().mockReturnValue(bottomGroupParent), click: vi.fn() }; leaf.getParent = vi.fn().mockReturnValue(leafParent); topGroup.getParent = vi.fn().mockReturnValue(null); bottomGroup.getParent = vi.fn().mockReturnValue(topGroup); diff --git a/code/packages/sdk/src/plugins/nodes-selection/__tests__/events/pointer-down.test.ts b/code/packages/sdk/src/plugins/nodes-selection/__tests__/events/pointer-down.test.ts index a3a4c249f..45b18eb18 100644 --- a/code/packages/sdk/src/plugins/nodes-selection/__tests__/events/pointer-down.test.ts +++ b/code/packages/sdk/src/plugins/nodes-selection/__tests__/events/pointer-down.test.ts @@ -161,6 +161,8 @@ function makeTarget(attrs: Record = {}, parent: unknown = {}) { getClassName: () => 'Rect', getAttrs: vi.fn().mockReturnValue(attrs), getParent: vi.fn().mockReturnValue(parent), + dblClick: vi.fn(), + click: vi.fn(), }; } @@ -320,6 +322,7 @@ describe('handlePointerDown', () => { const realNodeB = { getAttrs: () => ({ id: 'B', nodeType: 'rectangle' }), getParent: () => null, + click: vi.fn(), }; ctx.getWeaveInstance().getRealSelectedNode = vi .fn() @@ -353,6 +356,7 @@ describe('handlePointerDown', () => { const realNodeB = { getAttrs: () => ({ id: 'B', nodeType: 'rectangle' }), getParent: () => null, + click: vi.fn(), }; ctx.getWeaveInstance().getRealSelectedNode = vi .fn() diff --git a/code/packages/sdk/src/plugins/nodes-selection/events/click-tap.ts b/code/packages/sdk/src/plugins/nodes-selection/events/click-tap.ts index 862a6fc33..543fb4a29 100644 --- a/code/packages/sdk/src/plugins/nodes-selection/events/click-tap.ts +++ b/code/packages/sdk/src/plugins/nodes-selection/events/click-tap.ts @@ -160,6 +160,29 @@ export function handleClickOrTap( } const isCtrlOrCmdPressed = e.evt.ctrlKey || e.evt.metaKey; + + // Generic per-node click hook (e.g. text-node hyperlinks), invoked here + // rather than as a raw Konva event on the node itself: once a node is + // selected its hit area is covered by the Transformer's own overdraw + // shape (see Transformer's `back` proxy, used to drag-move the whole + // selection), so a `node.on('pointerclick', ...)` listener registered + // directly on the node would stop firing for any click while it's + // selected. `nodeTargeted` here is already correctly re-resolved through + // that overlay (same as `dblClick()` above), so it stays reliable + // regardless of selection state. Skipped on shift-click, which is a + // multi-select gesture, not a per-node interaction. + if (!e.evt.shiftKey) { + const wasSelectedBeforeThisClick = + tr + .nodes() + .findIndex((node) => node.getAttrs().id === nodeTargeted.getAttrs().id) !== + -1; + nodeTargeted.click({ + wasSelected: wasSelectedBeforeThisClick, + ctrlOrMetaPressed: isCtrlOrCmdPressed, + }); + } + if (isCtrlOrCmdPressed) return; if (!metaPressed) { diff --git a/docs/.release-version b/docs/.release-version index c7cb1311a..8a30e8f94 100644 --- a/docs/.release-version +++ b/docs/.release-version @@ -1 +1 @@ -5.3.1 +5.4.0 diff --git a/docs/src/modules/sdk/pages/api-reference/nodes/text.adoc b/docs/src/modules/sdk/pages/api-reference/nodes/text.adoc index ccc2dd41d..07c0fb152 100644 --- a/docs/src/modules/sdk/pages/api-reference/nodes/text.adoc +++ b/docs/src/modules/sdk/pages/api-reference/nodes/text.adoc @@ -43,8 +43,29 @@ new WeaveTextNode(params?: WeaveTextNodeParams); ---- type WeaveNodeTransformerProperties = Konva.TransformerConfig; +type WeaveTextOutlineProperties = + | { enabled: true; color: string; width: number } + | { enabled: false }; + +type WeaveTextEditionProperties = { + borderSize: number; +}; + +type WeaveTextCursorProperties = { + color: string; +}; + +type WeaveTextLinkProperties = { + defaultColor: string; + hoverColor: string; +}; + type WeaveTextProperties = { transform: WeaveNodeTransformerProperties; + outline: WeaveTextOutlineProperties; + edition: WeaveTextEditionProperties; + cursor: WeaveTextCursorProperties; + link: WeaveTextLinkProperties; }; type WeaveTextNodeParams = { @@ -70,6 +91,10 @@ For `WeaveTextProperties`: | Property | Type | Required | Default | Description | mono:[transform] | mono:[object] | | mono:[check default values] | Setup the transform properties for the text (if can be resized, rotated, anchors, etc.). +| mono:[outline] | mono:[object] | | mono:[{ enabled: false }] | Setup an outline (stroke) around the text glyphs. +| mono:[edition] | mono:[object] | | mono:[{ borderSize: 2 }] | Border size, in pixels, of the overlay shown while editing the text in-place. +| mono:[cursor] | mono:[object] | | mono:[{ color: "#000000" }] | Caret colour used by the in-place text editor. +| mono:[link] | mono:[object] | | mono:[check default values] | Default and hover colours applied to a text node once it has a `link` set — see the "Hyperlinks" section below. |=== == Default values @@ -98,4 +123,79 @@ const WEAVE_DEFAULT_TRANSFORM_PROPERTIES: WeaveNodeTransformerProperties = { borderStrokeWidth: 3, padding: 0, }; + +const WEAVE_TEXT_NODE_DEFAULT_CONFIG: WeaveTextProperties = { + transform: WEAVE_DEFAULT_TRANSFORM_PROPERTIES, + outline: { + enabled: false, + }, + cursor: { + color: "#000000", + }, + edition: { + borderSize: 2, + }, + link: { + defaultColor: "#1155ccff", + hoverColor: "#3d7be0ff", + }, +}; +---- + +== Hyperlinks + +A text node can link its whole text to an external URL by setting its `link` attribute (a +plain string, part of the node's props like `fill` or `text`) to an absolute `http://` or +`https://` URL — any other scheme (e.g. `javascript:`, `data:`) is rejected, both by the node's +zod schema and again defensively before the link is opened, since the value is user-authored +content that other collaborators will click on. + +Behaviour while `link` is set (per-node, does not affect nodes without a link): + +* The text is always rendered underlined and coloured with mono:[link.defaultColor], regardless + of the node's own `fill`/`textDecoration`. The real values are captured once, the first time a + link is applied, into two internal bookkeeping props — `linkPreviousFill` and + `linkPreviousTextDecoration` — and restored from there whenever the link is removed or the node + is re-serialized (e.g. after a resize, a drag, or exiting text-edit mode) while still linked, so + they're never lost to the forced link styling. These two props are managed automatically; don't + set them directly. +* Hovering the text swaps its colour to mono:[link.hoverColor] and the cursor to a pointer. +* Clicking opens the URL in a new tab (mono:[window.open(url, "_blank", "noopener,noreferrer")]). + Because a plain click on any node already means "select/drag it", opening the link uses two + gestures layered on top of that, both working identically on mouse and touch: +** Clicking/tapping a text node that is *already selected* opens its link (a first click on an + unselected node still only selects it, same as any other node). +** On desktop, holding kbd:[Ctrl] (or kbd:[Cmd] on macOS) while clicking opens the link + immediately, whether or not the node was already selected. +* Rich/partial-text linking is out of scope — the link always applies to the node's entire text. + +=== Methods + +==== getLink + +[source,ts] +---- +getLink(nodeInstance: WeaveElementInstance): string | undefined ---- + +Returns the URL currently set on the given text node instance, or mono:[undefined] if it has no link. + +==== setLink + +[source,ts] +---- +setLink(nodeInstance: WeaveElementInstance, url: string): void +---- + +Sets (or replaces) the URL a text node links to. mono:[url] must be an absolute `http://` or +`https://` URL; any other value is a no-op. + +==== removeLink + +[source,ts] +---- +removeLink(nodeInstance: WeaveElementInstance): void +---- + +Removes the link from a text node. The node's original `fill`/`textDecoration` reappear +immediately, since they were never altered while the link was set.