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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,18 @@ and this project adheres to

### Added

- ✨(frontend) add local history grouping controls and test checkpoints
- 👷(ci) lint, typecheck, build and test the collaboration server (yhub)
- 👷(ci) check the load-test tooling: swarm, canary, k6 and dashboards
- 👷(ci) cancel the superseded runs of a pull request
- ✨(backend) add mention endpoint with cooldown-limited email
notification #2447
- 🚩(setting) add feature flag on Duplicate with Children #2721
- ✨(frontend) export PDF with Typst, including math and diagrams
- ✨(frontend) show yhub version history in the BlockNote sidebar
- ✨(frontend) provide consistent author and cursor colors in BlockNote
- 💄(frontend) share deterministic user colors across cursors and avatars
- 💄(frontend) derive readable author colors with matching pastel highlights
- 💄(frontend) redesign 404 error standalone page #2696
- 💄(frontend) redesign 403 access denied page #2720
- ✨(frontend) duplicate with subdocuments #2584
Expand Down Expand Up @@ -76,6 +82,11 @@ and this project adheres to
### Changed

- 🛂(backend) let users allowed to comment list each other's accesses #2447
- ⬆️(frontend) update BlockNote preview packages to 8d235ba9eb
- ♻️(frontend) restore bounded editor history through native yhub rollback
- ♻️(frontend) use standalone YHub version storage and history extension
- ♻️(frontend) migrate the history panel to RenderInPortalElement
- ⬆️(collaboration) upgrade yhub to 0.9.1 with matching Yjs
- ♻️(collaboration) migrate the collaboration server from hocuspocus to yhub
- 💥(y-provider) y-provider becomes converter-only
- 💥(backend) move the resource server JWKS from `/api/{version}/jwks` to
Expand All @@ -87,11 +98,14 @@ and this project adheres to

### Fixed

- 🐛(frontend) fetch named versions fresh after deleting one
- 🐛(documentation) fix minio port and keycloak realm in the k8s guide #2751
- 🐛(collaboration) bound the calls yhub makes to the backend with a timeout,
`YHUB_BACKEND_REQUEST_TIMEOUT_MS`, 5s by default #2753
- 🐛(frontend) cache hashed `/_next/static/` assets and revalidate HTML #2770
- 🐛(backend) compensate document duplication failures #2755
- 🐛(collaboration) restore editor versions without exposing full history
- 🐛(collaboration) grant bounded named-version access for the history sidebar
- 🐛(frontend) open search results in a new tab with ctrl/cmd+click #2719
- 🐛(docker) pull minio images from pgsty
- 🐛(backend) retry the duplicate of a document on a tree path collision
Expand Down
34 changes: 17 additions & 17 deletions src/frontend/apps/impress/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,17 +24,17 @@
"dependencies": {
"@ag-media/react-pdf-table": "2.0.3",
"@ai-sdk/openai": "3.0.119",
"@blocknote/code-block": "0.54.0",
"@blocknote/core": "0.54.0",
"@blocknote/diagram-block": "0.54.0",
"@blocknote/mantine": "0.54.0",
"@blocknote/math-block": "0.54.0",
"@blocknote/react": "0.54.0",
"@blocknote/xl-ai": "0.54.0",
"@blocknote/xl-docx-exporter": "0.54.0",
"@blocknote/xl-multi-column": "0.54.0",
"@blocknote/xl-odt-exporter": "0.54.0",
"@blocknote/xl-pdf-exporter": "0.54.0",
"@blocknote/code-block": "https://pkg.pr.new/TypeCellOS/BlockNote/@blocknote/code-block@8d235ba9eb",
"@blocknote/core": "https://pkg.pr.new/TypeCellOS/BlockNote/@blocknote/core@8d235ba9eb",
"@blocknote/diagram-block": "https://pkg.pr.new/TypeCellOS/BlockNote/@blocknote/diagram-block@8d235ba9eb",
"@blocknote/mantine": "https://pkg.pr.new/TypeCellOS/BlockNote/@blocknote/mantine@8d235ba9eb",
"@blocknote/math-block": "https://pkg.pr.new/TypeCellOS/BlockNote/@blocknote/math-block@8d235ba9eb",
"@blocknote/react": "https://pkg.pr.new/TypeCellOS/BlockNote/@blocknote/react@8d235ba9eb",
"@blocknote/xl-ai": "https://pkg.pr.new/TypeCellOS/BlockNote/@blocknote/xl-ai@8d235ba9eb",
"@blocknote/xl-docx-exporter": "https://pkg.pr.new/TypeCellOS/BlockNote/@blocknote/xl-docx-exporter@8d235ba9eb",
"@blocknote/xl-multi-column": "https://pkg.pr.new/TypeCellOS/BlockNote/@blocknote/xl-multi-column@8d235ba9eb",
"@blocknote/xl-odt-exporter": "https://pkg.pr.new/TypeCellOS/BlockNote/@blocknote/xl-odt-exporter@8d235ba9eb",
"@blocknote/xl-pdf-exporter": "https://pkg.pr.new/TypeCellOS/BlockNote/@blocknote/xl-pdf-exporter@8d235ba9eb",
"@dnd-kit/core": "6.3.1",
"@dnd-kit/modifiers": "9.0.0",
"@emoji-mart/data": "1.2.1",
Expand All @@ -54,7 +54,10 @@
"@tanstack/react-query": "5.103.2",
"@tiptap/extension-find-and-replace": "3.31.3",
"@tiptap/extensions": "*",
"@y/yhub-http-fallback": "0.1.1",
"@y/prosemirror": "^2.0.0-14",
"@y/protocols": "^1.0.6-rc.1",
"@y/websocket": "^4.0.0-3",
"@y/y": "^14.0.0-rc.26",
"ai": "6.0.295",
"canvg": "4.0.3",
"clsx": "2.1.1",
Expand All @@ -80,13 +83,10 @@
"react-resizable-panels": "3.0.6",
"react-select": "5.10.2",
"styled-components": "6.5.3",
"tex2typst": "0.6.2",
"use-debounce": "10.1.1",
"uuid": "14.0.2",
"y-indexeddb": "9.0.12",
"y-prosemirror": "1.3.7",
"y-protocols": "1.0.7",
"y-websocket": "3.1.0",
"yjs": "*",
"lib0": "1.0.0-rc.33",
"zod": "4.6.5",
"zustand": "5.0.15"
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@ type AvatarSvgProps = BoxProps &
Omit<ComponentPropsWithRef<'svg'>, keyof BoxProps> & {
initials: string;
background: string;
foreground?: string;
fontFamily?: string;
};

export const AvatarSvg: React.FC<AvatarSvgProps> = ({
initials,
background,
foreground = 'rgba(255,255,255,0.9)',
fontFamily,
...props
}) => (
Expand Down Expand Up @@ -42,7 +44,7 @@ export const AvatarSvg: React.FC<AvatarSvgProps> = ({
textAnchor="middle"
fontSize="10"
fontWeight="600"
fill="rgba(255,255,255,0.9)"
fill={foreground}
fontFamily={fontFamily || 'Arial'}
>
{initials}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,64 +1,53 @@
import { renderToStaticMarkup } from 'react-dom/server';

import { tokens } from '@/cunningham';
import { userColorForeground, userColorsForId } from '../userColors';

import { AvatarSvg } from './AvatarSvg';

const colors = tokens.themes.default.globals.colors;

const avatarsColors = [
colors['blue-1-500'],
colors['brown-500'],
colors['green-500'],
colors['orange-500'],
colors['pink-500'],
colors['purple-500'],
colors['yellow-500'],
];

const getColorFromName = (name: string) => {
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash);
}
return avatarsColors[Math.abs(hash) % avatarsColors.length];
};

const getInitialFromName = (name: string) => {
const splitName = name?.split(' ');
return (splitName[0]?.charAt(0) || '?') + (splitName?.[1]?.charAt(0) || '');
};

type UserAvatarProps = {
userId?: string;
fullName?: string;
background?: string;
};

export const UserAvatar = ({ fullName, background }: UserAvatarProps) => {
export const UserAvatar = ({
userId,
fullName,
background,
}: UserAvatarProps) => {
const name = fullName?.trim() || '?';
const color = background || userColorsForId(userId ?? name).color;

return (
<AvatarSvg
className="--docs--user-avatar"
initials={getInitialFromName(name).toUpperCase()}
background={background || getColorFromName(name)}
background={color}
foreground={userColorForeground(color)}
/>
);
};

export const avatarUrlFromName = (
fullName?: string,
fontFamily?: string,
userId?: string,
): string => {
const name = fullName?.trim() || '?';
const initials = getInitialFromName(name).toUpperCase();
const background = getColorFromName(name);
const background = userColorsForId(userId ?? name).color;

const svgMarkup = renderToStaticMarkup(
<AvatarSvg
className="--docs--user-avatar"
initials={initials}
background={background}
foreground={userColorForeground(background)}
fontFamily={fontFamily}
/>,
);
Expand Down
64 changes: 64 additions & 0 deletions src/frontend/apps/impress/src/features/auth/userColors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// Seed one hue per identity. Use a readable dark shade for text/cursors and
// the original pastel shade for author highlights.
export const userColorsForId = (id: string) => {
let hash = 2166136261;
for (let i = 0; i < id.length; i++) {
hash = Math.imul(hash ^ id.charCodeAt(i), 16777619);
}

const nextInt = (min: number, max: number) => {
hash = (hash + 0x6d2b79f5) | 0;
let value = Math.imul(hash ^ (hash >>> 15), 1 | hash);
value ^= value + Math.imul(value ^ (value >>> 7), 61 | value);
const fraction = ((value ^ (value >>> 14)) >>> 0) / 4294967296;
return min + Math.floor(fraction * (max - min + 1));
};

const hue = nextInt(0, 360);
const saturation = nextInt(42, 98);
const lightness = nextInt(70, 90);
const colorLight = hslToHex(hue, saturation, lightness);
const lightLuminance = relativeLuminance(colorLight);
let darkLightness = 40;
let color = hslToHex(hue, saturation, darkLightness);

// Yellow/green hues need more darkening than blue to keep text readable.
while (
darkLightness > 1 &&
(lightLuminance + 0.05) / (relativeLuminance(color) + 0.05) < 4.5
) {
color = hslToHex(hue, saturation, --darkLightness);
}

return {
color,
colorLight,
};
};

// Same relative-luminance threshold as BlockNote's default cursor renderer.
export const userColorForeground = (background: string) =>
relativeLuminance(background) <= 0.179 ? '#ffffff' : '#000000';

function relativeLuminance(hex: string) {
const channels = [1, 3, 5].map((offset) => {
const value = parseInt(hex.slice(offset, offset + 2), 16) / 255;
return value <= 0.03928
? value / 12.92
: Math.pow((value + 0.055) / 1.055, 2.4);
});
return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2];
}

function hslToHex(h: number, s: number, l: number) {
l /= 100;
const a = (s * Math.min(l, 1 - l)) / 100;
const channel = (n: number) => {
const k = (n + h / 30) % 12;
const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
return Math.round(255 * color)
.toString(16)
.padStart(2, '0');
};
return `#${channel(0)}${channel(8)}${channel(4)}`;
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { CommentBody, ThreadStore } from '@blocknote/core/comments';
import type { Awareness } from 'y-protocols/awareness';
import * as Y from 'yjs';
import type { Awareness } from '@y/protocols/awareness';
import * as Y from '@y/y';

import { APIError, errorCauses, fetchAPI } from '@/api';
import { Doc } from '@/features/docs/doc-management';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useConfig } from '@/core';
import { useCunninghamTheme } from '@/cunningham';
import { User, avatarUrlFromName } from '@/features/auth';
import { Doc, useProviderStore } from '@/features/docs/doc-management';
import { useDocAccesses } from '@/features/docs/doc-share/api/useDocAccesses';

import { DocsThreadStore } from '../api/DocsThreadStore';
import { DocsThreadStoreAuth } from '../api/DocsThreadStoreAuth';
Expand All @@ -20,6 +21,7 @@ export function useComments(
const { themeTokens } = useCunninghamTheme();
const { setThreadStore } = useThreadStore();
const { data: config } = useConfig();
const { data: accesses } = useDocAccesses({ docId });

const threadStore = useMemo(() => {
return new DocsThreadStore(
Expand Down Expand Up @@ -61,22 +63,38 @@ export function useComments(

const resolveUsers = useCallback(
async (userIds: string[]) => {
// Legacy comments identify authors by name, not by user id. Only
// resolve an unambiguous name; unknown authors keep a stable fallback.
const knownUsers = new Map<string, User>();
if (user) {
knownUsers.set(user.id, user);
}
accesses?.forEach(({ user: author }) => {
if (author) {
knownUsers.set(author.id, author);
}
});
return Promise.resolve(
userIds.map((encodedURIUserId) => {
const fullName = decodeURIComponent(encodedURIUserId);
const matches = Array.from(knownUsers.values()).filter(
(author) => author.full_name === fullName,
);
const authorId = matches.length === 1 ? matches[0].id : undefined;

return {
id: encodedURIUserId,
username: fullName || t('Anonymous'),
avatarUrl: avatarUrlFromName(
fullName,
themeTokens?.font?.families?.base,
authorId,
),
};
}),
);
},
[t, themeTokens?.font?.families?.base],
[t, themeTokens?.font?.families?.base, user, accesses],
);

return { threadStore, resolveUsers };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ vi.mock('../../doc-management', async () => {
provider: {
roomname: 'test-doc-id',
doc: {
getXmlFragment: () => null,
get: () => null,
},
},
isReady: true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
ComponentProps,
useBlockNoteEditor,
useComponentsContext,
usePortalElement,
useSelectedBlocks,
} from '@blocknote/react';
import { Loader, VariantType } from '@gouvfr-lasuite/ui-components';
Expand Down Expand Up @@ -55,6 +56,7 @@ const sortByPopularLanguages = (

export function AIGroupButton() {
const editor = useBlockNoteEditor();
const portalElement = usePortalElement();
const Components = useComponentsContext();
const selectedBlocks = useSelectedBlocks(editor);
const { t } = useTranslation();
Expand Down Expand Up @@ -98,7 +100,7 @@ export function AIGroupButton() {
}

return (
<Components.Generic.Menu.Root>
<Components.Generic.Menu.Root portalElement={portalElement}>
<Components.Generic.Menu.Trigger>
<Components.FormattingToolbar.Button
className="bn-button bn-menu-item --docs--ai-actions-menu-trigger"
Expand Down Expand Up @@ -159,7 +161,11 @@ export function AIGroupButton() {
</>
)}
{canAITranslate && (
<Components.Generic.Menu.Root position="right" sub={true}>
<Components.Generic.Menu.Root
position="right"
sub={true}
portalElement={portalElement}
>
<Components.Generic.Menu.Trigger sub={false}>
<Components.Generic.Menu.Item
className="bn-menu-item --docs--ai-translate-menu-trigger"
Expand Down
Loading
Loading