diff --git a/StorylinesSchema.json b/StorylinesSchema.json index a3895734..3e8380bc 100644 --- a/StorylinesSchema.json +++ b/StorylinesSchema.json @@ -103,7 +103,7 @@ "type": { "type": "string", "enum": ["dynamic"] - }, + }, "modified": { "type": "boolean", "description": "An optional tag that specifies whether the panel has been modified from its default configuration" @@ -189,7 +189,7 @@ "title": { "type": "string", "description": "A title that is displayed centered above this map." - }, + }, "caption": { "type": "string", "description": "Supporting text content for the map." @@ -289,7 +289,7 @@ "type": { "type": "string", "enum": ["video"] - }, + }, "modified": { "type": "boolean", "description": "An optional tag that specifies whether the panel has been modified from its default configuration" @@ -361,7 +361,20 @@ "type": { "type": "string", "description": "The type of chart.", - "enum": ["line", "spline", "area", "areaspline", "column", "bar", "pie", "scatter", "gauge", "arearange", "areasplinerange", "columnrange"] + "enum": [ + "line", + "spline", + "area", + "areaspline", + "column", + "bar", + "pie", + "scatter", + "gauge", + "arearange", + "areasplinerange", + "columnrange" + ] }, "width": { "type": "number", @@ -436,6 +449,10 @@ "blurb": { "type": "string", "description": "Any additional information to display on the introductory slide." + }, + "backgroundImage": { + "type": "string", + "description": "A background image for the introduction slide." } }, "required": ["logo", "title"] diff --git a/src/components/helpers/metadata-content.vue b/src/components/helpers/metadata-content.vue index 7381ca9c..307785c7 100644 --- a/src/components/helpers/metadata-content.vue +++ b/src/components/helpers/metadata-content.vue @@ -115,13 +115,13 @@ {{ $t('editor.browse') }} @@ -155,7 +155,7 @@ @@ -178,6 +178,66 @@ {{ metadata.logoAltText || $t('editor.metadataForm.na') }} + + + + {{ $t('editor.introBackground') }} + + + + + + + {{ $t('editor.browse') }} + + + + {{ $t('editor.remove') }} + + + + {{ $t('editor.metadataForm.caption.introBackground') }} + + + + {{ metadata.introBgName || $t('editor.metadataForm.na') }} + + + + {{ $t('editor.introBackgroundPreview') }}: + + + {{ $t('editor.image.loadingError') }} + + + + Intro background upload + + @@ -245,8 +305,8 @@ export default class MetadataEditorV extends Vue { @Prop() metadata!: MetadataContent; @Prop({ default: true }) editing!: boolean; - openFileSelector(): void { - document.getElementById('logoUpload')?.click(); + openFileSelector(where: string = 'logoUpload'): void { + document.getElementById(where)?.click(); } metadataChanged(event: Event): void { @@ -261,6 +321,11 @@ export default class MetadataEditorV extends Vue { this.metadata.logoName = ''; this.metadata.logoPreview = ''; } + + removeIntroBackground(): void { + this.metadata.introBgName = ''; + this.metadata.introBgPreview = ''; + } } diff --git a/src/components/metadata-editor.vue b/src/components/metadata-editor.vue index 9a1226a6..545ebc61 100644 --- a/src/components/metadata-editor.vue +++ b/src/components/metadata-editor.vue @@ -437,8 +437,8 @@ :metadata="metadata" :editing="editingMetadata" @metadata-changed="updateMetadata" - @logo-changed="onFileChange" - @logo-source-changed="onLogoSourceInput" + @image-changed="onFileChange" + @image-source-changed="onImageSourceInput" > @@ -540,8 +540,8 @@ { - this.logoImage = new File([img], this.metadata.logoName); - this.metadata.logoPreview = URL.createObjectURL(img); - this.loadStatus = 'loaded'; - }); - } else { - logoFile.async('text').then((img) => { - const logoImageFile = new File([img], this.metadata.logoName, { - type: 'image/svg+xml' - }); - this.logoImage = logoImageFile; - this.metadata.logoPreview = URL.createObjectURL(logoImageFile); - this.loadStatus = 'loaded'; - }); + // Load product logo and the introduction slide background image (if provided). + const logoAsset = this.configs[this.configLang]?.introSlide.logo?.src; + const introBgAsset = this.configs[this.configLang]?.introSlide.backgroundImage; + + Promise.all([ + this.processAsset(logoAsset, this.metadata.logoName), + this.processAsset(introBgAsset, this.metadata.introBgName) + ]).then(([logoData, introBgData]) => { + if (logoData) { + this.logoImage = logoData.file; + this.metadata.logoPreview = logoData.preview; + + // If an external source, fill in the name field whether it exists or not. + if (logoData.external) { + this.metadata.logoName = logoAsset ?? ''; } - } else { - // Fill in the field with this value whether it exists or not. - this.metadata.logoName = logo; - - // If it doesn't exist, maybe it's a remote file? - fetch(logo).then((data: Response) => { - if (data.status !== 404) { - data.blob().then((blob: Blob) => { - this.logoImage = new File([blob], this.metadata.logoName); - this.metadata.logoPreview = logo; - this.loadStatus = 'loaded'; - }); - } - }); } - } else { - // No logo to load. + + if (introBgData) { + this.introBgImage = introBgData.file; + this.metadata.introBgPreview = introBgData.preview; + + // If an external source, fill in the name field whether it exists or not. + if (introBgData.external) { + this.metadata.introBgName = introBgAsset ?? ''; + } + } + this.loadStatus = 'loaded'; - } + }); return; } @@ -824,6 +817,53 @@ export default class MetadataEditorV extends Vue { } } + processAsset(asset: string | undefined, name: string): Promise { + if (!asset) return Promise.resolve(); + + // Load asset (if provided). + return new Promise((res) => { + const assetSrc = `assets/${this.configLang}/${name}`; + const assetFile = this.configFileStructure?.zip.file(assetSrc); + const assetType = assetSrc.split('.').at(-1); + + if (assetFile) { + if (assetType !== 'svg') { + assetFile.async('blob').then((img: Blob) => { + res({ + file: new File([img], name), + preview: URL.createObjectURL(img), + external: false // indicates that this asset lives in the ZIP folder + }); + }); + } else { + assetFile.async('text').then((img) => { + const assetImageFile = new File([img], name, { + type: 'image/svg+xml' + }); + res({ + file: assetImageFile, + preview: URL.createObjectURL(assetImageFile), + external: false + }); + }); + } + } else { + // If it doesn't exist, maybe it's a remote file? + fetch(asset).then((data: Response) => { + if (data.status !== 404) { + data.blob().then((blob: Blob) => { + res({ + file: new File([blob], name), + preview: asset, + external: true // indicates that this is an external asset + }); + }); + } + }); + } + }); + } + /** * Open current editor config as a new Storylines product in new tab. * Note: Preview button on metadata editor will only show when editing an existing product, not cwhen creating a new one @@ -890,6 +930,16 @@ export default class MetadataEditorV extends Vue { } else { config.introSlide.logo.src = this.metadata.logoName; } + + // Set the source of the introduction slide background image + if (!this.metadata.introBgName) { + config.introSlide.backgroundImage = ''; + } else if (!this.metadata.introBgName.includes('http')) { + config.introSlide.backgroundImage = `${this.uuid}/assets/${this.configLang}/${this.introBgImage?.name}`; + } else { + config.introSlide.backgroundImage = this.metadata.introBgName; + } + config.slides = []; const otherLang = this.configLang === 'en' ? 'fr' : 'en'; @@ -905,7 +955,7 @@ export default class MetadataEditorV extends Vue { configZip.file(`${this.uuid}_${otherLang}.json`, formattedOtherLangConfig); // Generate the file structure, defer uploading the image until the structure is created. - this.configFileStructureHelper(configZip, this.logoImage); + this.configFileStructureHelper(configZip, [this.logoImage, this.introBgImage]); } configHelper(): StoryRampConfig { @@ -1214,7 +1264,7 @@ export default class MetadataEditorV extends Vue { * Generates or loads a ZIP file and creates required project folders if needed. * Returns an object that makes it easy to access any specific folder. */ - configFileStructureHelper(configZip: typeof JSZip, uploadLogo?: File | undefined): void { + configFileStructureHelper(configZip: typeof JSZip, uploadFiles?: Array): void { const assetsFolder = configZip.folder('assets'); const chartsFolder = configZip.folder('charts'); const rampConfigFolder = configZip.folder('ramp-config'); @@ -1234,9 +1284,14 @@ export default class MetadataEditorV extends Vue { rampConfig: rampConfigFolder as JSZip }; - // If uploadLogo is set, upload the logo to the directory. - if (uploadLogo !== undefined) { - this.configFileStructure.assets[this.configLang].file(uploadLogo?.name, uploadLogo); + // Upload each file in the `uploadFiles` array to the ZIP folder. This is typically (and currently only) used + // for the product logo and the introduction slide background image. + if (uploadFiles !== undefined) { + uploadFiles.forEach((file) => { + if (file) { + this.configFileStructure!.assets[this.configLang].file(file?.name, file); + } + }); } this.loadConfig(); @@ -1303,55 +1358,47 @@ export default class MetadataEditorV extends Vue { this.loadSlides(this.configs); - const logo = config.introSlide.logo?.src; - if (logo) { - // Set the alt text for the logo. - this.metadata.logoAltText = config.introSlide.logo?.altText ? config.introSlide.logo.altText : ''; - this.metadata.logoName = logo.split('/').at(-1); - - // Fetch the logo from the folder (if it exists). - const logoSrc = `${logo.substring(logo.indexOf('/') + 1)}`; - const logoName = `${logo.split('/')[logo.split('/').length - 1]}`; - const logoFile = this.configFileStructure?.zip.file(logoSrc); - const logoType = logoSrc.split('.').at(-1); - if (logoFile) { - if (logoType !== 'svg') { - logoFile.async('blob').then((img: Blob) => { - this.logoImage = new File([img], this.metadata.logoName); - this.metadata.logoPreview = URL.createObjectURL(img); - this.loadStatus = 'loaded'; - }); + // Load product logo and the introduction slide background image (if provided). + const logoAsset = config.introSlide.logo?.src; + const introBgAsset = config.introSlide.backgroundImage; + + // If the logo source is provided, grab the path without the UUID and the file name. + + const logoPath = logoAsset ? logoAsset.substring(logoAsset.indexOf('/') + 1) : undefined; + const logoName = logoAsset ? logoAsset.split('/')[logoAsset.split('/').length - 1] : ''; + + // Grab the asset file name. + const introBgPath = introBgAsset ? introBgAsset.substring(introBgAsset.indexOf('/') + 1) : undefined; + const introBgName = introBgAsset ? introBgAsset.split('/')[introBgAsset.split('/').length - 1] : ''; + + // Load in the data from the logo and intro slide background image. If one of these assets is missing, the promise will resolve with undefined. + Promise.all([ + this.processAsset(logoPath, logoName).then((logoData) => { + if (logoData) { + this.metadata.logoAltText = config.introSlide.logo?.altText ? config.introSlide.logo.altText : ''; + this.logoImage = logoData.file; + this.metadata.logoPreview = logoData.preview; + this.metadata.logoName = logoName; } else { - logoFile.async('text').then((img) => { - const logoImageFile = new File([img], this.metadata.logoName, { - type: 'image/svg+xml' - }); - this.logoImage = logoImageFile; - this.metadata.logoPreview = URL.createObjectURL(logoImageFile); - this.loadStatus = 'loaded'; - }); + // If there's no logo, mark the product as loaded and remove any existing logos + this.metadata.logoName = ''; + this.metadata.logoPreview = ''; } - } else { - // Fill in the field with this value whether it exists or not. - this.metadata.logoName = logo; - - // If it doesn't exist, maybe it's a remote file? - fetch(logo).then((data: Response) => { - if (data.status !== 404) { - data.blob().then((blob: Blob) => { - this.logoImage = new File([blob], logoName); - this.metadata.logoPreview = logo; - this.loadStatus = 'loaded'; - }); - } - }); - } - } else { - // If there's no logo, mark the product as loaded and remove any existing logos - this.metadata.logoName = ''; - this.metadata.logoPreview = ''; + }), + this.processAsset(introBgPath, introBgName).then((introBgData) => { + if (introBgData) { + this.introBgImage = introBgData.file; + this.metadata.introBgPreview = introBgData.preview; + this.metadata.introBgName = introBgName; + } else { + this.metadata.introBgName = ''; + this.metadata.introBgPreview = ''; + } + }) + ]).then(() => { + // Once assets are loaded, set status to loaded. this.loadStatus = 'loaded'; - } + }); // Load the temp copy of the metadata this.temporaryMetadataCopy = JSON.parse(JSON.stringify(this.metadata)); @@ -1536,6 +1583,19 @@ export default class MetadataEditorV extends Vue { config.introSlide.logo.src = this.metadata.logoName; } + // If the introduction slide background image doesn't include HTTP, assume it's a local file. + if (!this.metadata.introBgName) { + config.introSlide.backgroundImage = ''; + } else if (!this.metadata.introBgName.includes('http')) { + config.introSlide.backgroundImage = `${this.uuid}/assets/${this.configLang}/${this.introBgImage?.name}`; + this.configFileStructure?.assets[this.configLang].file( + this.introBgImage?.name as string, + this.introBgImage as File + ); + } else { + config.introSlide.backgroundImage = this.metadata.introBgName; + } + if (publish) { this.generateConfig(); this.temporaryMetadataCopy = JSON.parse(JSON.stringify(this.metadata)); @@ -1630,7 +1690,7 @@ export default class MetadataEditorV extends Vue { next(); } - onLogoSourceInput(e: InputEvent): void { + onImageSourceInput(e: InputEvent, src: string): void { const isImgUrl = (url: string) => { const img = new Image(); img.src = url; @@ -1640,27 +1700,61 @@ export default class MetadataEditorV extends Vue { }); }; - this.metadata.logoName = (e.target as HTMLInputElement).value; + switch (src) { + case 'logo': + this.metadata.logoName = (e.target as HTMLInputElement).value; - isImgUrl(this.metadata.logoName).then((res) => { - if (res) { - this.metadata.logoPreview = this.metadata.logoName; - Message.success(this.$t('editor.editMetadata.message.logoSuccessfulLoad')); - } else { - this.metadata.logoPreview = 'error'; - Message.error(this.$t('editor.editMetadata.message.error.logoFailedLoad')); - } - }); + isImgUrl(this.metadata.logoName).then((res) => { + if (res) { + this.metadata.logoPreview = this.metadata.logoName; + Message.success(this.$t('editor.editMetadata.message.imageSuccessfulLoad')); + } else { + this.metadata.logoPreview = 'error'; + Message.error(this.$t('editor.editMetadata.message.error.imageFailedLoad')); + } + }); + + break; + case 'introBg': + this.metadata.introBgName = (e.target as HTMLInputElement).value; + + isImgUrl(this.metadata.introBgName).then((res) => { + if (res) { + this.metadata.introBgPreview = this.metadata.introBgName; + Message.success(this.$t('editor.editMetadata.message.imageSuccessfulLoad')); + } else { + this.metadata.introBgPreview = 'error'; + Message.error(this.$t('editor.editMetadata.message.error.imageFailedLoad')); + } + }); + break; + default: + console.error('onImageSourceInput received invalid source.'); + } } - onFileChange(e: Event): void { + onFileChange(e: Event, src: string): void { // Retrieve the uploaded file. const uploadedFile = ((e.target as HTMLInputElement).files as ArrayLike)[0]; - this.logoImage = uploadedFile; - // Generate an image preview. - this.metadata.logoPreview = URL.createObjectURL(uploadedFile); - this.metadata.logoName = uploadedFile.name; + switch (src) { + case 'logo': + this.logoImage = uploadedFile; + + // Generate an image preview. + this.metadata.logoPreview = URL.createObjectURL(uploadedFile); + this.metadata.logoName = uploadedFile.name; + break; + case 'introBg': + this.introBgImage = uploadedFile; + + // Generate an image preview. + this.metadata.introBgPreview = URL.createObjectURL(uploadedFile); + this.metadata.introBgName = uploadedFile.name; + break; + default: + console.error('onFileChange received invalid source.'); + } } updateEditorPath(): void { diff --git a/src/definitions.ts b/src/definitions.ts index 2b67efd4..de1b71e8 100644 --- a/src/definitions.ts +++ b/src/definitions.ts @@ -36,6 +36,8 @@ export interface MetadataContent { logoPreview: string; logoName: string; logoAltText: string; + introBgName: string; + introBgPreview: string; contextLink: string; contextLabel: string; tocOrientation: string; @@ -133,6 +135,7 @@ export interface Intro { title: string; subtitle: string; blurb?: string; + backgroundImage?: string; } export interface Slide { diff --git a/src/lang/lang.csv b/src/lang/lang.csv index 09a71866..ccd70198 100644 --- a/src/lang/lang.csv +++ b/src/lang/lang.csv @@ -49,9 +49,9 @@ editor.editMetadata.message.error.noRequestedVersion,The requested version does editor.editMetadata.message.error.noResponseFromServer,"Failed to load product, no response from server",1,"Échec du chargement du produit, aucune réponse du serveur",0 editor.editMetadata.message.error.malformedProduct,The requested product {uuid} is malformed.,1,Le produit demandé {uuid} est mal formé.,0 editor.editMetadata.message.error.failedSave,Failed to save changes.,1,Échec de l'enregistrement des modifications.,0 -editor.editMetadata.message.error.logoFailedLoad,Failed to load logo image.,1,Échec du chargement de l'image du logo.,0 +editor.editMetadata.message.error.imageFailedLoad,Failed to load image.,1,Échec du chargement de l'image.,0 editor.editMetadata.message.error.requiredFieldsNotFilled,Please fill out the required fields before proceeding.,1,Veuillez remplir les champs obligatoires avant de continuer.,0 -editor.editMetadata.message.logoSuccessfulLoad,Successfully loaded logo image.,1,Image du logo chargée avec succès.,0 +editor.editMetadata.message.imageSuccessfulLoad,Successfully loaded image.,1,Logo chargé avec succès.,0 editor.editMetadata.message.successfulLoad,Successfully loaded storyline!,1,Scénario chargé avec succès!,0 editor.editMetadata.message.successfulSave,Successfully saved changes!,1,Modifications enregistrées avec succès!,0 editor.editMetadata.message.wait,Please wait. Saving may take a few moments.,1,"Veuillez patienter. L'enregistrement peut prendre quelques instants.",0 @@ -70,6 +70,7 @@ editor.metadataForm.caption.tocOrientation,"Determines the direction of the stor editor.metadataForm.caption.introTitle,"The intro title is the title displayed in large text on the first slide, right underneath the logo (if given).",1,"Le titre d'introduction est le titre affiché en gros texte sur la première diapositive, juste en dessous du logo (s'il est fourni).",0 editor.metadataForm.caption.introSubtitle,"The intro subtitle displays underneath the intro title, in small text.",1,"Le sous-titre d'introduction s'affiche sous le titre d'introduction, en petit texte.",0 editor.metadataForm.caption.logoAltText,"For accessibility purposes, provide description text for the logo.",1,"À des fins d’accessibilité, veuillez fournir un texte de description pour le logo.",0 +editor.metadataForm.caption.introBackground,"The background image is displayed on the first slide of the storyline product, behind the intro title and subtitle.",1,"L'image d'arrière-plan est affichée sur la première diapositive du produit de scénario, derrière le titre et le sous-titre d'introduction.",0 editor.metadataForm.caption.contextLink,"Context link shows up at the bottom of the page to provide additional resources for interested users.",1,"Le lien contextuel apparaît au bas de la page pour fournir des ressources supplémentaires aux utilisateurs intéressés.",0 editor.metadataForm.caption.contextLabel,"Context label shows up as text. When users click the context link, the linked site appears in a new tab for the user.",1,"L'étiquette de contexte s'affiche sous forme de texte. Lorsque les utilisateurs cliquent sur le lien contextuel, le site lié apparaît dans un nouvel onglet pour l'utilisateur.",0 editor.metadataForm.introPage.heading,Introduction page details,1,Détails de la page d'introduction,0 @@ -104,6 +105,8 @@ editor.logoPreview,Logo preview,1,Aperçu du logo,1 editor.logoPreviewAltText,Preview of product logo,1,Aperçu du logo du produit,0 editor.logoAltText,Logo alt text,1,Lien contextuel,1 editor.logoAltText.desc,"For accessibility purposes, provide description text for the logo.",1,"Pour des raisons d'accessibilité, fournissez un texte descriptif pour le logo.",0 +editor.introBackground,Background image,1,Image d'arrière-plan,0 +editor.introBackgroundPreview,Background image preview,1,Aperçu de l'image d'arrière-plan,0 editor.contextLink,Context link,1,Lien contextuel,1 editor.contextLink.info,Context link shows up at the bottom of the page to provide additional resources for interested users.,1,Le lien contextuel apparaît au bas de la page et fournit des ressources supplémentaires aux utilisateurs intéressés.,1 editor.contextLabel,Context label,1,Étiquette de contexte,1
{{ metadata.logoAltText || $t('editor.metadataForm.na') }}
+ {{ $t('editor.metadataForm.caption.introBackground') }} +
{{ metadata.introBgName || $t('editor.metadataForm.na') }}
+ {{ $t('editor.image.loadingError') }} +