diff --git a/app/assets/stylesheets/pageflow/editor/file_settings_dialog.scss b/app/assets/stylesheets/pageflow/editor/file_settings_dialog.scss index 33ce33e43e..c7cc5b6edb 100644 --- a/app/assets/stylesheets/pageflow/editor/file_settings_dialog.scss +++ b/app/assets/stylesheets/pageflow/editor/file_settings_dialog.scss @@ -3,7 +3,7 @@ min-width: 500px; min-height: 400px; max-width: 800px; - max-height: 600px; + max-height: 790px; } .edit_file { diff --git a/config/locales/de.yml b/config/locales/de.yml index 34402866e4..2fd4e4f49b 100644 --- a/config/locales/de.yml +++ b/config/locales/de.yml @@ -1217,6 +1217,22 @@ de: video_settings_tab: Video-Details files: common_attributes: + ai_indicator: + blank: "(Nicht angegeben)" + inline_help: |- + Kennzeichne, dass die Datei mit künstlicher Intelligenz + erzeugt oder verändert wurde. Wird als EU-KI-Label an + der Stelle im Beitrag angezeigt, wo die Datei verwendet + wird. + label: KI-Kennzeichnung + values: + ai_generated: Vollständig KI-generiert + ai_modified: Teilweise KI-modifiziert + ai_indicator_text: + inline_help: |- + Erläutert, welche Teile der Datei mit künstlicher + Intelligenz erzeugt oder verändert wurden. + label: Text der KI-Kennzeichnung alt: inline_help: Ein Hinweis darauf, was in dem dargestellten Element zum Ausdruck kommt. Der Text ist im Beitrag nicht sichtbar, kann aber von Screen-Readern verarbeitet werden und trägt somit zur barrierearmen Gestaltung der Inhalte bei. label: Alt-Text diff --git a/config/locales/en.yml b/config/locales/en.yml index c2e30093d5..0cc741b2a9 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1210,6 +1210,21 @@ en: video_settings_tab: Video Details files: common_attributes: + ai_indicator: + blank: "(Not specified)" + inline_help: |- + Indicate that the file has been generated or modified + using artificial intelligence. Displayed as EU AI label + at the element where the file is used. + label: AI label + values: + ai_generated: Fully AI generated + ai_modified: Partially AI modified + ai_indicator_text: + inline_help: |- + Explains which parts of the file have been generated or + modified using artificial intelligence. + label: AI label text alt: inline_help: A hint describing the contents of the displayed element. Not displayed in the story, but can be used by screen reader software. Improves accessibility of the content. label: Alt-Text diff --git a/entry_types/scrolled/config/locales/new/public.de.yml b/entry_types/scrolled/config/locales/new/public.de.yml index 35d38e0748..605cae9da9 100644 --- a/entry_types/scrolled/config/locales/new/public.de.yml +++ b/entry_types/scrolled/config/locales/new/public.de.yml @@ -1,6 +1,10 @@ de: pageflow_scrolled: public: + ai_indicators: + ai: KI + ai_generated: KI-generiert + ai_modified: KI-modifiziert enter_fullscreen: Vollbildmodus öffnen exit_fullscreen: Vollbildmodus verlassen flip_card: Karte wenden diff --git a/entry_types/scrolled/config/locales/new/public.en.yml b/entry_types/scrolled/config/locales/new/public.en.yml index 05e105e3de..19051f85a2 100644 --- a/entry_types/scrolled/config/locales/new/public.en.yml +++ b/entry_types/scrolled/config/locales/new/public.en.yml @@ -1,6 +1,10 @@ en: pageflow_scrolled: public: + ai_indicators: + ai: AI + ai_generated: AI generated + ai_modified: AI modified enter_fullscreen: Enter fullscreen exit_fullscreen: Exit fullscreen flip_card: Flip card diff --git a/entry_types/scrolled/package/documentation.yml b/entry_types/scrolled/package/documentation.yml index 606f662b21..d4df087bb7 100644 --- a/entry_types/scrolled/package/documentation.yml +++ b/entry_types/scrolled/package/documentation.yml @@ -15,6 +15,7 @@ toc: description: | React components for building content elements. children: + - AiIndicatorIcon - AudioPlayer - ContentElementBox - ContentElementFigure diff --git a/entry_types/scrolled/package/spec/frontend/AiIndicatorIcon-spec.js b/entry_types/scrolled/package/spec/frontend/AiIndicatorIcon-spec.js new file mode 100644 index 0000000000..a6068cd79f --- /dev/null +++ b/entry_types/scrolled/package/spec/frontend/AiIndicatorIcon-spec.js @@ -0,0 +1,57 @@ +import React from 'react'; +import '@testing-library/jest-dom/extend-expect'; + +import {AiIndicatorIcon, useAiIndicatorLabel} from 'frontend/AiIndicatorIcon'; + +import {renderInEntry} from 'support'; +import {useFakeTranslations} from 'pageflow/testHelpers'; + +describe('AiIndicatorIcon', () => { + useFakeTranslations({ + 'pageflow_scrolled.public.ai_indicators.ai_generated': 'AI generated' + }); + + it('renders icon for kind', () => { + const {container} = renderInEntry(); + + expect(container.querySelector('svg')).toHaveAttribute('data-file-name', + 'SvgAiGenerated'); + }); + + it('renders different icon per kind', () => { + const {container} = renderInEntry(); + + expect(container.querySelector('svg')).toHaveAttribute('data-file-name', + 'SvgAiModified'); + }); + + it('renders nothing for unknown kind', () => { + const {container} = renderInEntry(); + + expect(container.querySelector('svg')).toBeNull(); + }); + + it('renders translated label for screen readers', () => { + const {queryByRole} = renderInEntry(); + + expect(queryByRole('img', {name: 'AI generated'})).not.toBeNull(); + }); +}); + +describe('useAiIndicatorLabel', () => { + useFakeTranslations({ + 'pageflow_scrolled.public.ai_indicators.ai_generated': 'AI generated' + }); + + function Probe({kind}) { + const aiIndicatorLabel = useAiIndicatorLabel(); + + return {aiIndicatorLabel(kind)}; + } + + it('translates kind', () => { + const {container} = renderInEntry(); + + expect(container).toHaveTextContent('AI generated'); + }); +}); diff --git a/entry_types/scrolled/package/spec/frontend/InlineFileRights-spec.js b/entry_types/scrolled/package/spec/frontend/InlineFileRights-spec.js index fce8b7c85a..ac5e27e741 100644 --- a/entry_types/scrolled/package/spec/frontend/InlineFileRights-spec.js +++ b/entry_types/scrolled/package/spec/frontend/InlineFileRights-spec.js @@ -9,8 +9,11 @@ import {useFakeTranslations} from 'pageflow/testHelpers'; describe('InlineFileRights', () => { useFakeTranslations({ + 'pageflow_scrolled.public.inline_file_rights_labels.image': 'Image', 'pageflow_scrolled.public.inline_file_rights_labels.poster': 'Poster', 'pageflow_scrolled.public.inline_file_rights_labels.video': 'Video', + 'pageflow_scrolled.public.ai_indicators.ai_generated': 'AI generated', + 'pageflow_scrolled.public.ai_indicators.ai_modified': 'AI modified' }); beforeEach(() => { @@ -21,6 +24,17 @@ describe('InlineFileRights', () => { ) } }); + + api.widgetTypes.register('inlineFileRightsFlags', { + component: function ({hasRights, hasAiIndicators, children}) { + return ( +
+ {`${hasRights}|${hasAiIndicators}`} + {children} +
+ ) + } + }); }); const seed = { @@ -222,6 +236,213 @@ describe('InlineFileRights', () => { .toHaveAttribute('href', 'https://stock.example.com/123'); }); + it('renders ai indicator icon and text as part of item', () => { + const file = { + id: 10, + rights: 'My stock images', + configuration: { + ai_indicator: 'ai_modified', + ai_indicator_text: 'Sky replaced' + }, + inlineRights: true + }; + + const {getByRole, queryByRole} = renderInEntry( + , + {seed} + ); + + expect(getByRole('listitem')).toHaveTextContent('Image: My stock images Sky replaced'); + expect(queryByRole('img', {name: 'AI modified'})).not.toBeNull(); + }); + + it('keeps label, rights and license together for line breaking', () => { + const file = { + id: 10, + rights: 'My stock images', + configuration: {ai_indicator: 'ai_generated'}, + inlineRights: true, + license: { + name: 'CC0', + url: 'https://creativecommons.org/publicdomain/zero/1.0/' + } + }; + + const {container} = renderInEntry( + , + {seed} + ); + + expect(container.querySelector('[data-part="rights"]')) + .toHaveTextContent('Image: My stock images (CC0)'); + }); + + it('keeps ai indicator icon and text together for line breaking', () => { + const file = { + id: 10, + rights: 'My stock images', + configuration: { + ai_indicator: 'ai_modified', + ai_indicator_text: 'Sky replaced' + }, + inlineRights: true + }; + + const {container, getByRole} = renderInEntry( + , + {seed} + ); + + expect(getByRole('img', {name: 'AI modified'}).parentElement) + .toHaveAttribute('data-part', 'ai-indicator'); + expect(container.querySelector('[data-part="ai-indicator"]')) + .toHaveTextContent('Sky replaced'); + }); + + it('renders item for file with ai indicator even without inline rights', () => { + const file = { + id: 10, + rights: '', + configuration: { + ai_indicator: 'ai_generated', + ai_indicator_text: 'Voices cloned' + }, + inlineRights: false + }; + + const {getByRole} = renderInEntry( + , + {seed} + ); + + expect(getByRole('listitem')).toHaveTextContent('Video: Voices cloned'); + }); + + it('renders one item per file with ai indicator', () => { + const videoFile = { + id: 10, + configuration: {ai_indicator: 'ai_generated', ai_indicator_text: 'Voices'}, + inlineRights: false + }; + const posterFile = { + id: 11, + configuration: {ai_indicator: 'ai_generated', ai_indicator_text: 'Voices'}, + inlineRights: false + }; + + const {queryAllByRole} = renderInEntry( + , + {seed} + ); + + const listItems = queryAllByRole('listitem'); + + expect(listItems).toHaveLength(2); + expect(listItems[0]).toHaveTextContent('Video: Voices'); + expect(listItems[1]).toHaveTextContent('Poster: Voices'); + }); + + it('marks items that have rights', () => { + const file = { + id: 10, + rights: 'My stock images', + configuration: {}, + inlineRights: true + }; + + const {getByRole} = renderInEntry( + , + {seed} + ); + + expect(getByRole('listitem')).toHaveAttribute('data-rights', ''); + }); + + it('does not mark items that only have an ai indicator', () => { + const file = { + id: 10, + rights: '', + configuration: {ai_indicator: 'ai_generated'}, + inlineRights: false + }; + + const {getByRole} = renderInEntry( + , + {seed} + ); + + expect(getByRole('listitem')).not.toHaveAttribute('data-rights'); + }); + + it('marks labels of items to allow hiding them', () => { + const file = { + id: 10, + rights: 'My stock images', + configuration: {}, + inlineRights: true + }; + + const {container} = renderInEntry( + , + {seed} + ); + + expect(container.querySelector('[data-label]')).toHaveTextContent('Image:'); + }); + + it('does not render license of file without inline rights', () => { + const file = { + id: 10, + rights: 'My stock images', + configuration: {ai_indicator: 'ai_generated'}, + inlineRights: false, + license: { + name: 'CC0', + url: 'https://creativecommons.org/publicdomain/zero/1.0/' + } + }; + + const {getByRole} = renderInEntry( + , + {seed} + ); + + expect(getByRole('listitem')).not.toHaveTextContent('CC0'); + }); + + it('passes flags for rights and ai indicators to widget', () => { + const file = { + id: 10, + rights: '', + configuration: {ai_indicator: 'ai_generated'}, + inlineRights: false + }; + + const {container} = renderInEntry( + , + {seed: {widgets: [{role: 'inlineFileRights', typeName: 'inlineFileRightsFlags'}]}} + ); + + expect(container).toHaveTextContent('false|true'); + }); + + it('does not render widget for files without rights and ai indicator', () => { + const file = { + id: 10, + rights: '', + configuration: {}, + inlineRights: true + }; + + const {container} = renderInEntry( + , + {seed} + ); + + expect(container.textContent).toEqual(''); + }); + it('renders license links for for files with license', () => { const file = { id: 10, diff --git a/entry_types/scrolled/package/spec/support/__spec__/stories-spec.js b/entry_types/scrolled/package/spec/support/__spec__/stories-spec.js index ca803b9834..678f549922 100644 --- a/entry_types/scrolled/package/spec/support/__spec__/stories-spec.js +++ b/entry_types/scrolled/package/spec/support/__spec__/stories-spec.js @@ -218,6 +218,79 @@ describe('exampleStories', () => { })); }); + it('supports adding stories with AI indicators', () => { + stubSeedFixture(normalizeSeed({ + imageFiles: [ + {id: 10, permaId: 1} + ], + videoFiles: [ + {id: 20, permaId: 2} + ] + })); + + const stories = exampleStories({ + typeName: 'test', + inlineFileRights: true, + aiIndicators: true, + baseConfiguration: {} + }); + + expect(stories).toContainEqual(expect.objectContaining({ + title: 'Inline File Rights - Icon with AI Indicators', + seed: expect.objectContaining({ + collections: expect.objectContaining({ + imageFiles: [ + expect.objectContaining({ + configuration: expect.objectContaining({ai_indicator: 'ai_modified'}) + }) + ], + videoFiles: [ + expect.objectContaining({ + configuration: expect.objectContaining({ai_indicator: 'ai_generated'}) + }) + ] + }) + }) + })); + }); + + it('keeps stories without AI indicators when adding stories with them', () => { + stubSeedFixture(normalizeSeed({ + imageFiles: [ + {id: 10, permaId: 1} + ] + })); + + const stories = exampleStories({ + typeName: 'test', + inlineFileRights: true, + aiIndicators: true, + baseConfiguration: {} + }); + + const story = stories.find(story => story.title === 'Inline File Rights - Icon'); + + expect(story.seed.collections.imageFiles[0].configuration) + .not.toHaveProperty('ai_indicator'); + }); + + it('does not add stories with AI indicators by default', () => { + stubSeedFixture(normalizeSeed({ + imageFiles: [ + {id: 10, permaId: 1} + ] + })); + + const stories = exampleStories({ + typeName: 'test', + inlineFileRights: true, + baseConfiguration: {} + }); + + expect(stories.map(story => story.title)) + .not.toContain('Inline File Rights - Icon with AI Indicators'); + }); + it('supports adding story for inline file rights', () => { stubSeedFixture(normalizeSeed({ imageFiles: [ @@ -313,4 +386,24 @@ describe('normalizeAndMergeFixture', () => { } }) }); + + it('supports AI indicators for inline file rights', () => { + stubSeedFixture(normalizeSeed({ + imageFiles: [ + {id: 10, permaId: 1} + ] + })); + + const seed = normalizeAndMergeFixture({ + inlineFileRightsFor: ['imageFiles'], + aiIndicators: true + }); + + expect(seed.collections.imageFiles[0]).toMatchObject({ + configuration: { + ai_indicator: 'ai_modified', + ai_indicator_text: expect.any(String) + } + }) + }); }) diff --git a/entry_types/scrolled/package/spec/support/stories.js b/entry_types/scrolled/package/spec/support/stories.js index 14ad924079..ab7298f9e6 100644 --- a/entry_types/scrolled/package/spec/support/stories.js +++ b/entry_types/scrolled/package/spec/support/stories.js @@ -38,6 +38,12 @@ let seedFixture = seedFixtureFromFile; * @param {boolean} [consent=false] - * Set to true, to include a story which renders the content element * in a state where consent has not yet been given. + * @param {boolean} [inlineFileRights=false] - + * Set to true, to include stories which render the content element + * with file rights displayed inline. + * @param {boolean} [aiIndicators=false] - + * Set to true, to add further inline file rights stories in which + * files are marked as AI generated or AI modified. * * @example * @@ -175,6 +181,13 @@ const inlineFileRightsCollectionNames = [ 'audioFiles', 'imageFiles', 'lottieFiles', 'videoFiles' ]; +const aiIndicatorsByCollectionName = { + audioFiles: {kind: 'ai_generated', text: 'Voices cloned'}, + imageFiles: {kind: 'ai_modified', text: 'Sky replaced'}, + lottieFiles: {kind: 'ai_generated', text: 'Shapes generated'}, + videoFiles: {kind: 'ai_generated', text: 'Scene generated'} +}; + export function exampleStories(options) { return [ ...variantsExampleStories(options), @@ -287,7 +300,7 @@ function consentOptInStories({typeName, consent, baseConfiguration}) { }); } -function inlineFileRightsStories({typeName, inlineFileRights, baseConfiguration}) { +function inlineFileRightsStories({typeName, inlineFileRights, aiIndicators, baseConfiguration}) { if (!inlineFileRights) { return []; } @@ -297,22 +310,30 @@ function inlineFileRightsStories({typeName, inlineFileRights, baseConfiguration} name: 'Inline File Rights', inlineFileRightsFor: inlineFileRightsCollectionNames, examples: [ - ['Icon', 'iconInlineFileRights'], ['Text', 'textInlineFileRights'] - ].map(([name, typeName]) => ( - { - name, - widgets: [{ - role: 'inlineFileRights', - typeName - }], - contentElementConfiguration: { - ...baseConfiguration - } - } - )) + ...inlineFileRightsExamples({baseConfiguration}), + ...(aiIndicators ? inlineFileRightsExamples({baseConfiguration, aiIndicators: true}) : []) + ] }); } +function inlineFileRightsExamples({baseConfiguration, aiIndicators}) { + return [ + ['Icon', 'iconInlineFileRights'], ['Text', 'textInlineFileRights'] + ].map(([name, typeName]) => ( + { + name: aiIndicators ? `${name} with AI Indicators` : name, + aiIndicators, + widgets: [{ + role: 'inlineFileRights', + typeName + }], + contentElementConfiguration: { + ...baseConfiguration + } + } + )); +} + function exampleStoryGroup({ name, typeName, examples, viewport, consentVendors, inlineFileRightsFor, widgets }) { @@ -347,6 +368,7 @@ function exampleStoryGroup({ themeOptions: examples[index].themeOptions, consentVendors: exampleConsentVendors, inlineFileRightsFor: inlineFileRightsFor || examples[index].inlineFileRightsFor, + aiIndicators: examples[index].aiIndicators, contentElementConsentVendors: exampleConsentVendors && contentElements .filter(({id}) => id) @@ -401,7 +423,7 @@ function dasherize(text) { ); } -export function normalizeAndMergeFixture({inlineFileRightsFor = [], ...options} = {}) { +export function normalizeAndMergeFixture({inlineFileRightsFor = [], aiIndicators, ...options} = {}) { const seed = normalizeSeed(options); return { @@ -420,7 +442,10 @@ export function normalizeAndMergeFixture({inlineFileRightsFor = [], ...options} ...seedFixture.collections, ...inlineFileRightsFor.reduce((memo, collectionName) => ({ ...memo, - [collectionName]: applyInlineFileRights(seedFixture.collections[collectionName]) + [collectionName]: applyInlineFileRights( + seedFixture.collections[collectionName], + aiIndicators && aiIndicatorsByCollectionName[collectionName] + ) }), {}), storylines: seed.collections.storylines, chapters: seed.collections.chapters, @@ -431,14 +456,18 @@ export function normalizeAndMergeFixture({inlineFileRightsFor = [], ...options} }; } -function applyInlineFileRights(files = []) { +function applyInlineFileRights(files = [], aiIndicator) { return files.map(file => ({ ...file, rights: 'Jane Doe', configuration: { ...file.configuration, rights_display: 'inline', - source_url: 'https://example.com/jane-doe/image' + source_url: 'https://example.com/jane-doe/image', + ...(aiIndicator && { + ai_indicator: aiIndicator.kind, + ai_indicator_text: aiIndicator.text + }) } })); } diff --git a/entry_types/scrolled/package/spec/widgets/iconInlineFileRights/IconInlineFileRights-spec.js b/entry_types/scrolled/package/spec/widgets/iconInlineFileRights/IconInlineFileRights-spec.js new file mode 100644 index 0000000000..4dd2c67e6a --- /dev/null +++ b/entry_types/scrolled/package/spec/widgets/iconInlineFileRights/IconInlineFileRights-spec.js @@ -0,0 +1,82 @@ +import React from 'react'; +import '@testing-library/jest-dom/extend-expect'; + +import {IconInlineFileRights} from 'widgets/iconInlineFileRights/IconInlineFileRights'; + +import {renderInEntry} from 'support'; +import {useFakeTranslations} from 'pageflow/testHelpers'; + +describe('IconInlineFileRights', () => { + useFakeTranslations({ + 'pageflow_scrolled.public.ai_indicators.ai': 'AI' + }); + + it('renders items in tooltip', () => { + const {container} = renderInEntry( + +
  • Some rights
+
+ ); + + expect(container).toHaveTextContent('Some rights'); + }); + + it('renders copyright icon for rights', () => { + const {container} = renderInEntry( + + ); + + expect(container.querySelector('[data-file-name="SvgCopyright"]')).not.toBeNull(); + }); + + it('renders ai icon for ai indicators', () => { + const {queryByRole} = renderInEntry( + + ); + + expect(queryByRole('img', {name: 'AI'})).not.toBeNull(); + }); + + it('renders both icons in shared button', () => { + const {queryAllByRole, getByRole} = renderInEntry( + + ); + + expect(queryAllByRole('button')).toHaveLength(1); + expect(getByRole('button')).toContainElement(getByRole('img', {name: 'AI'})); + }); + + it('renders copyright icon last for the tooltip arrow to point at it', () => { + const {getByRole} = renderInEntry( + + ); + + const icons = getByRole('button').querySelectorAll('svg'); + + expect(icons[icons.length - 1]).toHaveAttribute('data-file-name', 'SvgCopyright'); + }); + + it('does not render copyright icon without rights', () => { + const {container} = renderInEntry( + + ); + + expect(container.querySelector('[data-file-name="SvgCopyright"]')).toBeNull(); + }); + + it('does not render ai icon without ai indicators', () => { + const {queryByRole} = renderInEntry( + + ); + + expect(queryByRole('img')).toBeNull(); + }); + + it('renders nothing in afterElement context', () => { + const {container} = renderInEntry( + + ); + + expect(container.textContent).toEqual(''); + }); +}); diff --git a/entry_types/scrolled/package/src/contentElements/inlineAudio/stories.js b/entry_types/scrolled/package/src/contentElements/inlineAudio/stories.js index fa5410cddf..0e371e9600 100644 --- a/entry_types/scrolled/package/src/contentElements/inlineAudio/stories.js +++ b/entry_types/scrolled/package/src/contentElements/inlineAudio/stories.js @@ -74,5 +74,6 @@ storiesOfContentElement(module, { } } ], - inlineFileRights: true + inlineFileRights: true, + aiIndicators: true }); diff --git a/entry_types/scrolled/package/src/contentElements/inlineImage/stories.js b/entry_types/scrolled/package/src/contentElements/inlineImage/stories.js index 79a16b1bad..0cecde51fe 100644 --- a/entry_types/scrolled/package/src/contentElements/inlineImage/stories.js +++ b/entry_types/scrolled/package/src/contentElements/inlineImage/stories.js @@ -65,5 +65,6 @@ storiesOfContentElement(module, { } } ], - inlineFileRights: true + inlineFileRights: true, + aiIndicators: true }); diff --git a/entry_types/scrolled/package/src/contentElements/inlineVideo/stories.js b/entry_types/scrolled/package/src/contentElements/inlineVideo/stories.js index 4a4913e33c..ef3d58d80c 100644 --- a/entry_types/scrolled/package/src/contentElements/inlineVideo/stories.js +++ b/entry_types/scrolled/package/src/contentElements/inlineVideo/stories.js @@ -42,5 +42,6 @@ storiesOfContentElement(module, { } } ], - inlineFileRights: true + inlineFileRights: true, + aiIndicators: true }); diff --git a/entry_types/scrolled/package/src/frontend/AiIndicatorIcon/AiIndicatorIcon.module.css b/entry_types/scrolled/package/src/frontend/AiIndicatorIcon/AiIndicatorIcon.module.css new file mode 100644 index 0000000000..90ec3d4b84 --- /dev/null +++ b/entry_types/scrolled/package/src/frontend/AiIndicatorIcon/AiIndicatorIcon.module.css @@ -0,0 +1,5 @@ +/* The view boxes are cropped to the mark, so its antialiased edges + would be clipped. */ +.icon { + overflow: visible; +} diff --git a/entry_types/scrolled/package/src/frontend/AiIndicatorIcon/ai.svg b/entry_types/scrolled/package/src/frontend/AiIndicatorIcon/ai.svg new file mode 100644 index 0000000000..be674f1c6f --- /dev/null +++ b/entry_types/scrolled/package/src/frontend/AiIndicatorIcon/ai.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/entry_types/scrolled/package/src/frontend/AiIndicatorIcon/aiGenerated.svg b/entry_types/scrolled/package/src/frontend/AiIndicatorIcon/aiGenerated.svg new file mode 100644 index 0000000000..128c13f300 --- /dev/null +++ b/entry_types/scrolled/package/src/frontend/AiIndicatorIcon/aiGenerated.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/entry_types/scrolled/package/src/frontend/AiIndicatorIcon/aiModified.svg b/entry_types/scrolled/package/src/frontend/AiIndicatorIcon/aiModified.svg new file mode 100644 index 0000000000..26746842e7 --- /dev/null +++ b/entry_types/scrolled/package/src/frontend/AiIndicatorIcon/aiModified.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/entry_types/scrolled/package/src/frontend/AiIndicatorIcon/index.js b/entry_types/scrolled/package/src/frontend/AiIndicatorIcon/index.js new file mode 100644 index 0000000000..3d0e43b4a9 --- /dev/null +++ b/entry_types/scrolled/package/src/frontend/AiIndicatorIcon/index.js @@ -0,0 +1,42 @@ +import React from 'react'; +import classNames from 'classnames'; + +import {useI18n} from '../i18n'; + +import ai from './ai.svg'; +import aiGenerated from './aiGenerated.svg'; +import aiModified from './aiModified.svg'; + +import styles from './AiIndicatorIcon.module.css'; + +const icons = { + ai, + ai_generated: aiGenerated, + ai_modified: aiModified +}; + +/** + * Render one of the EU icons for labelling AI generated content. + * + * @param {Object} props + * @param {string} props.kind - Either: ai, ai_generated, ai_modified. + * @param {string} [props.className] + */ +export function AiIndicatorIcon({kind, className}) { + const aiIndicatorLabel = useAiIndicatorLabel(); + const Icon = icons[kind]; + + if (!Icon) { + return null; + } + + return ; +} + +export function useAiIndicatorLabel() { + const {t} = useI18n(); + + return kind => t(kind, {scope: 'pageflow_scrolled.public.ai_indicators'}); +} diff --git a/entry_types/scrolled/package/src/frontend/InlineFileRights.js b/entry_types/scrolled/package/src/frontend/InlineFileRights.js index 40809e1eab..8026da890e 100644 --- a/entry_types/scrolled/package/src/frontend/InlineFileRights.js +++ b/entry_types/scrolled/package/src/frontend/InlineFileRights.js @@ -1,5 +1,6 @@ import React from 'react'; +import {AiIndicatorIcon} from './AiIndicatorIcon'; import {useI18n} from './i18n'; import {isBlank} from './utils/blank'; import {Widget} from './Widget'; @@ -12,9 +13,8 @@ export function InlineFileRights({items = [], playerControlsFadedOut, playerControlsStandAlone, configuration = {}}) { - const {t} = useI18n(); const filteredItems = items.filter(item => - item.file && item.file.inlineRights && !isBlank(item.file.rights) + item.file && (hasRights(item.file) || hasAiIndicator(item.file)) ); if (!filteredItems.length) { @@ -25,22 +25,44 @@ export function InlineFileRights({items = [], + configuration, + hasRights: filteredItems.some(({file}) => hasRights(file)), + hasAiIndicators: filteredItems.some(({file}) => hasAiIndicator(file))}}>
    {filteredItems.map(({label, file}) => -
  • - - {label && - {t(label, {scope: 'pageflow_scrolled.public.inline_file_rights_labels'})}: } - {renderRights(file)} - {renderLicense(file)} -
  • + )}
); } +function Item({label, file}) { + const {t} = useI18n(); + + return ( +
  • + {(label || hasRights(file)) && + + {label && + {t(label, {scope: 'pageflow_scrolled.public.inline_file_rights_labels'})}: } + {hasRights(file) && renderRights(file)} + {hasRights(file) && renderLicense(file)} + } + {hasAiIndicator(file) && ' '} + {renderAiIndicator(file)} +
  • + ); +} + +function hasRights(file) { + return file.inlineRights && !isBlank(file.rights); +} + +function hasAiIndicator(file) { + return !!file.configuration.ai_indicator; +} + function renderRights(file) { if (isBlank(file.configuration.source_url)) { return file.rights; @@ -65,3 +87,17 @@ function renderLicense(file) { ); } + +function renderAiIndicator(file) { + if (!hasAiIndicator(file)) { + return null; + } + + return ( + + + {!isBlank(file.configuration.ai_indicator_text) && + ` ${file.configuration.ai_indicator_text}`} + + ); +} diff --git a/entry_types/scrolled/package/src/frontend/InlineFileRights.module.css b/entry_types/scrolled/package/src/frontend/InlineFileRights.module.css index 5d70b78197..2587ea1512 100644 --- a/entry_types/scrolled/package/src/frontend/InlineFileRights.module.css +++ b/entry_types/scrolled/package/src/frontend/InlineFileRights.module.css @@ -5,3 +5,22 @@ margin: 0; list-style: none; } + +.list > li { + display: flex; + flex-wrap: wrap; + align-items: baseline; + column-gap: 0.5em; +} + +/* 1.08em renders the "AI" glyphs at the height they have in the round + badge of the icon variant, which encloses them with more padding + than the pills do. The vertical align value centers the pill on the + cap height of the text, which sits above the x-height center that + `middle` uses. */ +.aiIcon { + height: 1.08em; + width: auto; + vertical-align: -0.19em; + margin-right: space(0.5); +} diff --git a/entry_types/scrolled/package/src/frontend/index.js b/entry_types/scrolled/package/src/frontend/index.js index 7e216b0b65..3c9541c5f4 100644 --- a/entry_types/scrolled/package/src/frontend/index.js +++ b/entry_types/scrolled/package/src/frontend/index.js @@ -133,6 +133,7 @@ export { export {FitViewport} from './FitViewport'; export {Tooltip} from './Tooltip'; export {ThemeIcon} from './ThemeIcon'; +export {AiIndicatorIcon, useAiIndicatorLabel} from './AiIndicatorIcon'; export {InlineFileRights} from './InlineFileRights'; export {useFloatingPortalRoot, FloatingPortalRootProvider} from './FloatingPortalRootProvider'; export {SectionIntersectionObserver} from './SectionIntersectionObserver'; diff --git a/entry_types/scrolled/package/src/widgets/iconInlineFileRights/IconInlineFileRights.js b/entry_types/scrolled/package/src/widgets/iconInlineFileRights/IconInlineFileRights.js index 34c023171e..f96ea42452 100644 --- a/entry_types/scrolled/package/src/widgets/iconInlineFileRights/IconInlineFileRights.js +++ b/entry_types/scrolled/package/src/widgets/iconInlineFileRights/IconInlineFileRights.js @@ -1,13 +1,16 @@ import React from 'react'; import classNames from 'classnames'; -import {ThemeIcon} from 'pageflow-scrolled/frontend'; +import {AiIndicatorIcon, ThemeIcon, useDarkBackground} from 'pageflow-scrolled/frontend'; import styles from './IconInlineFileRights.module.css'; export function IconInlineFileRights({ - context, position, playerControlsStandAlone, playerControlsFadedOut, children + context, position, playerControlsStandAlone, playerControlsFadedOut, + hasRights, hasAiIndicators, children }) { + const darkBackground = useDarkBackground(); + if (context === 'afterElement') { return null; } @@ -15,10 +18,14 @@ export function IconInlineFileRights({ return (
    diff --git a/entry_types/scrolled/package/src/widgets/iconInlineFileRights/IconInlineFileRights.module.css b/entry_types/scrolled/package/src/widgets/iconInlineFileRights/IconInlineFileRights.module.css index 8ddead8aa7..fcb47d4f7c 100644 --- a/entry_types/scrolled/package/src/widgets/iconInlineFileRights/IconInlineFileRights.module.css +++ b/entry_types/scrolled/package/src/widgets/iconInlineFileRights/IconInlineFileRights.module.css @@ -1,3 +1,15 @@ +@value ( + darkContentSurfaceColor, lightContentSurfaceColor +) from "pageflow-scrolled/values/colors.module.css"; + +.wrapper { + --ai-indicator-letter-color: darkContentSurfaceColor; +} + +.onLightBackground .button { + --ai-indicator-letter-color: lightContentSurfaceColor; +} + .standAlone { color: #fff; position: absolute; @@ -23,14 +35,16 @@ .button { border: 0; - padding: 0; + padding: 0 10px; background-color: transparent; - width: 44px; + box-sizing: border-box; + min-width: 44px; height: 40px; margin: 0; display: flex; align-items: center; justify-content: center; + gap: 4px; color: currentColor; cursor: pointer; position: relative; @@ -57,6 +71,11 @@ height: 23px; } +.button .aiIcon { + width: 19px; + height: 19px; +} + .standAlone .button svg { filter: drop-shadow(0 1px 4px #000); } @@ -111,11 +130,11 @@ text-decoration-color: rgba(255, 255, 255, 0.3); } -.tooltip li { +.tooltip [data-part] { white-space: nowrap; } -.tooltip span { +.tooltip [data-label] { color: rgba(255, 255, 255, 0.7); } diff --git a/entry_types/scrolled/package/src/widgets/textInlineFileRights/TextInlineFileRights.module.css b/entry_types/scrolled/package/src/widgets/textInlineFileRights/TextInlineFileRights.module.css index 11bb262893..ebae370d4e 100644 --- a/entry_types/scrolled/package/src/widgets/textInlineFileRights/TextInlineFileRights.module.css +++ b/entry_types/scrolled/package/src/widgets/textInlineFileRights/TextInlineFileRights.module.css @@ -9,19 +9,25 @@ position: relative; z-index: 2; color: var(--content-text-color); + --ai-indicator-letter-color: lightContentSurfaceColor; +} + +.text.darkBackground { + --ai-indicator-letter-color: darkContentSurfaceColor; } .text li { text-align: right; + justify-content: flex-end; padding-top: 0.2em; } -.text li::before { +.text li[data-rights] [data-part="rights"]::before { content: "© "; opacity: 0.7; } -.text:not(:has(li:nth-child(2))) span { +.text li:only-child [data-label] { display: none; } @@ -54,6 +60,6 @@ text-decoration-color: color-mix(in srgb, currentColor, transparent); } -.text span { +.text [data-label] { opacity: 0.7; } diff --git a/package/spec/editor/views/EditFileView-spec.js b/package/spec/editor/views/EditFileView-spec.js index bcb97ff532..ed871e40e2 100644 --- a/package/spec/editor/views/EditFileView-spec.js +++ b/package/spec/editor/views/EditFileView-spec.js @@ -1,4 +1,5 @@ import Backbone from 'backbone'; +import $ from 'jquery'; import {TextInputView} from 'pageflow/ui'; @@ -17,6 +18,10 @@ describe('EditFileView', () => { useFakeTranslations({ 'pageflow.editor.files.common_attributes.source_url.label': 'Source URL', 'pageflow.editor.files.common_attributes.license.label': 'License', + 'pageflow.editor.files.common_attributes.ai_indicator.label': 'AI indicator', + 'pageflow.editor.files.common_attributes.ai_indicator.values.ai_modified': 'AI modified', + 'pageflow.editor.files.common_attributes.ai_indicator.values.ai_generated': 'AI generated', + 'pageflow.editor.files.common_attributes.ai_indicator_text.label': 'AI indicator text', 'pageflow.file_licenses.cc0.name': 'CC0', 'pageflow.entry_types.strange.editor.files.attributes.image_files.custom.label': 'Entry Label', 'pageflow.editor.files.attributes.image_files.custom.label': 'Fallback Label', @@ -146,6 +151,79 @@ describe('EditFileView', () => { expect(getByRole('option', {name: 'CC0'})).not.toBeNull(); }); + it('renders ai indicator options', () => { + editor.registerEntryType('test', { + supportsExtendedFileRights: true + }); + var view = new EditFileView({ + model: f.file(), + entry: new Backbone.Model() + }); + + const {getByLabelText} = render(view); + const {getAllByRole} = within(getByLabelText('AI indicator')); + const options = getAllByRole('option').map(option => option.textContent); + + expect(options).toEqual(expect.arrayContaining(['AI modified', 'AI generated'])); + expect(options).not.toContain('AI'); + }); + + it('groups inputs with separators', () => { + editor.registerEntryType('test', { + supportsExtendedFileRights: true + }); + var view = new EditFileView({ + model: f.file(), + entry: new Backbone.Model() + }); + + render(view); + const sequence = view.$el.find('.input, .separator').toArray().map(element => + $(element).data('inputPropertyName') || 'separator' + ); + + expect(sequence).toEqual([ + 'display_name', + 'separator', + 'rights', 'source_url', 'license', 'rights_display', + 'separator', + 'ai_indicator', 'ai_indicator_text', + 'separator', + 'download_url' + ]); + }); + + it('hides ai indicator text input if no ai indicator is selected', () => { + editor.registerEntryType('test', { + supportsExtendedFileRights: true + }); + var view = new EditFileView({ + model: f.file(), + entry: new Backbone.Model() + }); + + render(view); + const configurationEditor = ConfigurationEditorTab.find(view); + + expect(configurationEditor.inputPropertyNames()).toContain('ai_indicator_text'); + expect(configurationEditor.visibleInputPropertyNames()).not.toContain('ai_indicator_text'); + }); + + it('displays ai indicator text input if ai indicator is selected', () => { + editor.registerEntryType('test', { + supportsExtendedFileRights: true + }); + var view = new EditFileView({ + model: f.file({configuration: {ai_indicator: 'ai_generated'}}), + entry: new Backbone.Model() + }); + + render(view); + const configurationEditor = ConfigurationEditorTab.find(view); + + expect(configurationEditor.visibleInputPropertyNames()).toContain('ai_indicator_text'); + }); + it('does not render extended file rights fields if non supported by file type', () => { var fileType = f.fileType({ noExtendedFileRights: true diff --git a/package/src/editor/views/EditFileView.js b/package/src/editor/views/EditFileView.js index 935c494ea6..31f14b96fb 100644 --- a/package/src/editor/views/EditFileView.js +++ b/package/src/editor/views/EditFileView.js @@ -36,6 +36,8 @@ export const EditFileView = Marionette.ItemView.extend({ required: true }); + tab.view(SeparatorView); + tab.input('rights', TextInputView, { model: this.model, placeholder: entry.get('default_file_rights') @@ -53,6 +55,16 @@ export const EditFileView = Marionette.ItemView.extend({ tab.input('rights_display', SelectInputView, { values: ['credits', 'inline'] }); + + tab.view(SeparatorView); + + tab.input('ai_indicator', SelectInputView, { + includeBlank: true, + values: ['ai_modified', 'ai_generated'] + }); + tab.input('ai_indicator_text', TextInputView, { + visibleBinding: 'ai_indicator' + }); } tab.view(SeparatorView);