diff --git a/package-lock.json b/package-lock.json index d27d34f7..095bd6dc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,7 +21,7 @@ "jszip": "^3.10.1", "markdown-it": "^12.0.6", "nouislider": "^15.5.0", - "ramp-storylines": "^3.0.4", + "ramp-storylines": "^3.0.6", "uuid": "^9.0.0", "vue": "^3.3.4", "vue-class-component": "^8.0.0-rc.1", @@ -15005,9 +15005,10 @@ } }, "node_modules/ramp-storylines": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/ramp-storylines/-/ramp-storylines-3.0.4.tgz", - "integrity": "sha512-xTzhJvjeDRNd/6nQD3IvfUib/JcN4hmc7RUcSindiVCCFllzTXlb56+RIdoMaLQ0VpJ5Z1XBVNlVPhJZUAZ2vg==", + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/ramp-storylines/-/ramp-storylines-3.0.6.tgz", + "integrity": "sha512-eF3YqWOGMg8XCFLqHDi0jcxuSxWoofyNHR3lKw1A4oQoSKc0god5uk8R6lPelrBFrlE0l0KLoRS+5XpGMMkdKQ==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", "dependencies": { "@tailwindcss/typography": "^0.4.0", "@types/highcharts": "^7.0.0", @@ -15026,6 +15027,7 @@ "vue-property-decorator": "^10.0.0-rc.2", "vue-router": "^4.2.4", "vue-tippy": "^6.3.1", + "vue-waypoint": "^4.3.0", "vue3-carousel": "^0.3.1", "vue3-scrollama": "^0.2.2", "vue3-spinners": "^1.2.2" @@ -19078,6 +19080,14 @@ "vue": "^3.2.0" } }, + "node_modules/vue-waypoint": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/vue-waypoint/-/vue-waypoint-4.3.0.tgz", + "integrity": "sha512-hze6CUkb7x+xiHp+rNChHTQB+wYeQ8OM4MHnJe78JBY4luK9fkDp/iAN1+gS5LNYU7iNbwAIBE7yIokRdoFh+A==", + "peerDependencies": { + "vue": "^3.0.0" + } + }, "node_modules/vue3-carousel": { "version": "0.3.1", "resolved": "https://registry.npmjs.org/vue3-carousel/-/vue3-carousel-0.3.1.tgz", diff --git a/package.json b/package.json index d13f48fc..1535c193 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "jszip": "^3.10.1", "markdown-it": "^12.0.6", "nouislider": "^15.5.0", - "ramp-storylines": "^3.0.4", + "ramp-storylines": "^3.0.6", "uuid": "^9.0.0", "vue": "^3.3.4", "vue-class-component": "^8.0.0-rc.1", diff --git a/src/components/editor/chart-editor.vue b/src/components/editor/chart-editor.vue index 56944770..2a61e417 100644 --- a/src/components/editor/chart-editor.vue +++ b/src/components/editor/chart-editor.vue @@ -12,6 +12,7 @@ class="chart-btn bg-gray-100 cursor-pointer hover:bg-gray-200" id="modal-btn" @click="clearEditor()" + v-if="allowMany || (!allowMany && chartConfigs.length === 0)" >
@@ -62,11 +63,18 @@ {{ $t('editor.chart.label.edit') }} | - {{ $t('editor.remove') }} + {{ $t('editor.remove') }} @@ -49,10 +49,7 @@ @@ -69,14 +66,8 @@ >
import { Options, Prop, Vue } from 'vue-property-decorator'; import { - ChartConfig, + BasePanel, ChartPanel, ConfigFileStructure, DefaultConfigs, @@ -100,19 +91,22 @@ import { MapPanel, PanelType, SlideshowPanel, - SourceCounts + SourceCounts, + TextPanel } from '@/definitions'; import ChartEditorV from './chart-editor.vue'; import ImageEditorV from './image-editor.vue'; import TextEditorV from './text-editor.vue'; import MapEditorV from './map-editor.vue'; +import SlideshowEditorV from './slideshow-editor.vue'; @Options({ components: { 'chart-editor': ChartEditorV, 'image-editor': ImageEditorV, 'text-editor': TextEditorV, + 'slideshow-editor': SlideshowEditorV, 'dynamic-editor': DynamicEditorV, 'map-editor': MapEditorV } @@ -126,7 +120,7 @@ export default class DynamicEditorV extends Vue { editors: Record = { text: 'text-editor', image: 'image-editor', - slideshow: 'image-editor', + slideshow: 'slideshow-editor', chart: 'chart-editor', map: 'map-editor' }; @@ -146,11 +140,16 @@ export default class DynamicEditorV extends Vue { }, slideshow: { type: PanelType.Slideshow, - images: [] + items: [], + userCreated: true + }, + image: { + type: PanelType.Image, + src: '' }, chart: { type: PanelType.Chart, - charts: [] + src: '' }, map: { type: PanelType.Map, @@ -181,19 +180,9 @@ export default class DynamicEditorV extends Vue { // Save slide changes if neccessary and switch to the newly selected slide. this.saveChanges(); this.editingSlide = idx; - - // Image Panel to Slideshow Panel Conversion - if (this.panel.children[this.editingSlide].panel.type === 'image') { - (this.panel.children[this.editingSlide].panel as SlideshowPanel) = { - type: PanelType.Slideshow, - images: [this.panel.children[this.editingSlide].panel as ImagePanel] - }; - } } - removeSlide(item: number): void { - const panel = this.panel.children.find((panel: DynamicChildItem, idx: number) => idx === item)?.panel; - + removeSlide(panel: BasePanel, index?: number): void { // Update source counts based on which panel is removed. switch (panel?.type) { case 'map': { @@ -209,33 +198,40 @@ export default class DynamicEditorV extends Vue { case 'chart': { const chartPanel = panel as ChartPanel; - chartPanel.charts.forEach((chart: ChartConfig) => { - this.sourceCounts[chart.src] -= 1; - if (this.sourceCounts[chart.src] === 0) { - this.configFileStructure.zip.remove(`${chart.src.substring(chart.src.indexOf('/') + 1)}`); - } - }); + this.sourceCounts[chartPanel.src] -= 1; + if (this.sourceCounts[chartPanel.src] === 0) { + this.configFileStructure.zip.remove(`${chartPanel.src.substring(chartPanel.src.indexOf('/') + 1)}`); + } + break; + } + + case 'image': { + const imagePanel = panel as ImagePanel; + + this.sourceCounts[imagePanel.src] -= 1; + if (this.sourceCounts[imagePanel.src] === 0) { + this.configFileStructure.zip.remove(`${imagePanel.src.substring(imagePanel.src.indexOf('/') + 1)}`); + } break; } case 'slideshow': { const slideshowPanel = panel as SlideshowPanel; - slideshowPanel.images.forEach((image: ImagePanel) => { - this.sourceCounts[image.src] -= 1; - if (this.sourceCounts[image.src] === 0) { - this.configFileStructure.zip.remove(`${image.src.substring(image.src.indexOf('/') + 1)}`); - } + slideshowPanel.items.forEach((item: TextPanel | ImagePanel | MapPanel | ChartPanel) => { + this.removeSlide(item); }); break; } } - // Remove the panel itself. - this.panel.children = this.panel.children.filter((panel: DynamicChildItem, idx: number) => idx !== item); + if (index) { + // Remove the panel itself. + this.panel.children = this.panel.children.filter((panel: DynamicChildItem, idx: number) => idx !== index); - // If the slide being removed is the currently selected slide, unselect it. - if (this.editingSlide === item) { - this.editingSlide = -1; + // If the slide being removed is the currently selected slide, unselect it. + if (this.editingSlide === index) { + this.editingSlide = -1; + } } } @@ -251,6 +247,23 @@ export default class DynamicEditorV extends Vue { this.panel.children.push(newConfig); } + determineEditorType(panel: BasePanel): string { + if (panel.type !== PanelType.Slideshow) return panel.type; + if ((panel as SlideshowPanel).items.length === 0 || (panel as SlideshowPanel).userCreated) + return PanelType.Slideshow; + + // Determine whether the slideshow consists of only charts. If so, display the chart editor. + const allCharts = (panel as SlideshowPanel).items.every((item: BasePanel) => item.type === PanelType.Chart); + if (allCharts) return PanelType.Chart; + + // Determine whether the slideshow consists of only images. If so, display the image editor. + const allImages = (panel as SlideshowPanel).items.every((item: BasePanel) => item.type === PanelType.Image); + if (allImages) return PanelType.Image; + + // Otherwise display the slideshow editor. + return PanelType.Slideshow; + } + saveChanges(): void { if ( this.$refs.slide !== undefined && diff --git a/src/components/editor/image-editor.vue b/src/components/editor/image-editor.vue index 36bd2494..736ff02f 100644 --- a/src/components/editor/image-editor.vue +++ b/src/components/editor/image-editor.vue @@ -7,6 +7,7 @@ @dragover.prevent="() => (dragging = true)" @dragleave.prevent="() => (dragging = false)" @drop.prevent="dropImages($event)" + v-if="allowMany || (!allowMany && imagePreviews.length === 0)" >
- + - + {{ $t('editor.image.reorder') }} @@ -77,10 +82,11 @@ import ImagePreviewV from '@/components/editor/helpers/image-preview.vue'; } }) export default class ImageEditorV extends Vue { - @Prop() panel!: SlideshowPanel; + @Prop() panel!: ImagePanel | SlideshowPanel; @Prop() configFileStructure!: ConfigFileStructure; @Prop() lang!: string; @Prop() sourceCounts!: SourceCounts; + @Prop({ default: true }) allowMany!: boolean; dragging = false; edited = false; @@ -95,12 +101,20 @@ export default class ImageEditorV extends Vue { } mounted(): void { - if (this.panel.images !== undefined && this.panel.images.length) { + // This basically allows us to access the image(s) using one consistent variable instead of needing to check panel type. + const images = + this.panel.type === PanelType.Slideshow + ? (this.panel.items as Array) + : this.panel.src + ? [this.panel] + : []; + + if (images !== undefined && images.length) { // Set images as loading until they are all loaded and resolve. this.imagePreviewsLoading = true; // Process each existing image. - this.panel.images.map((image: ImagePanel) => { + images.map((image: ImagePanel) => { // Check if the config file exists in the ZIP folder first. const assetSrc = `${image.src.substring(image.src.indexOf('/') + 1)}`; const filename = image.src.replace(/^.*[\\/]/, ''); @@ -158,7 +172,13 @@ export default class ImageEditorV extends Vue { dropImages(e: DragEvent): void { if (e.dataTransfer !== null) { - const files = [...e.dataTransfer.files]; + let files = [...e.dataTransfer.files]; + + // If allowMany is false, take the first one. + if (!this.allowMany) { + files = [files[0]]; + } + this.imagePreviews.push( ...files.map((file: File) => { // Add the uploaded images to the product ZIP file. @@ -203,14 +223,48 @@ export default class ImageEditorV extends Vue { saveChanges(): void { if (this.edited) { - this.panel.images = this.imagePreviews.map((imageFile: ImageFile) => { - return { - ...imageFile, - src: `${this.configFileStructure.uuid}/assets/${this.lang}/${imageFile.id}`, - type: PanelType.Image - }; + // Delete the existing properties so we can rebuild the object. + Object.keys(this.panel).forEach((key) => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + delete this.panel[key]; }); - this.panel.caption = this.slideshowCaption ?? undefined; + + // Handle case where everything is deleted. + if (this.imagePreviews.length === 0) { + this.panel.type = PanelType.Image; + (this.panel as ImagePanel).src = ''; + } else if (this.imagePreviews.length === 1) { + // If there's only one image uploaded, convert this to an image panel. + this.panel.type = PanelType.Image; + + // Grab the one image from the array. + const imageFile = this.imagePreviews[0]; + + // Sort of gross, but required to update the panel config as we're not allowed to directly manipulate props. + Object.keys(imageFile).forEach((key) => { + if (key === 'id') return; // we don't need this one. + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + (this.panel as ImagePanel)[key] = imageFile[key]; + }); + + (this.panel as ImagePanel).src = `${this.configFileStructure.uuid}/assets/${this.lang}/${imageFile.id}`; + } else { + // Otherwise, convert this to a slideshow panel. + this.panel.type = PanelType.Slideshow; + this.panel.caption = this.slideshowCaption ?? undefined; + + // Turn each of the image configs into an image panel and add them to the slidesow. + (this.panel as SlideshowPanel).items = this.imagePreviews.map((imageFile: ImageFile) => { + return { + ...imageFile, + src: `${this.configFileStructure.uuid}/assets/${this.lang}/${imageFile.id}`, + type: PanelType.Image + } as ImagePanel; + }); + } } this.edited = false; } diff --git a/src/components/editor/metadata-editor.vue b/src/components/editor/metadata-editor.vue index b3c84efb..37a20c7f 100644 --- a/src/components/editor/metadata-editor.vue +++ b/src/components/editor/metadata-editor.vue @@ -160,7 +160,6 @@ import { AxiosResponse } from 'axios'; import { AudioPanel, BasePanel, - ChartConfig, ChartPanel, ConfigFileStructure, DynamicChildItem, @@ -171,7 +170,8 @@ import { Slide, SlideshowPanel, SourceCounts, - StoryRampConfig + StoryRampConfig, + TextPanel } from '@/definitions'; import { VueSpinnerOval } from 'vue3-spinners'; import { VueFinalModal } from 'vue-final-modal'; @@ -435,16 +435,16 @@ export default class MetadataEditorV extends Vue { }); break; case 'slideshow': - (panel as SlideshowPanel).images.forEach((image: ImagePanel) => { - this.incrementSourceCount(image.src); + (panel as SlideshowPanel).items.forEach((item: ChartPanel | TextPanel | ImagePanel | MapPanel) => { + this.panelSourceHelper(item); }); break; case 'chart': - (panel as ChartPanel).charts.forEach((chart: ChartConfig) => { - this.incrementSourceCount(chart.src); - }); + this.incrementSourceCount((panel as ChartPanel).src); break; case 'image': + this.incrementSourceCount((panel as ImagePanel).src); + break; case 'video': case 'audio': this.incrementSourceCount((panel as AudioPanel).src); @@ -452,6 +452,8 @@ export default class MetadataEditorV extends Vue { case 'map': this.incrementSourceCount((panel as MapPanel).config); break; + case 'text': + break; default: break; } @@ -557,17 +559,7 @@ export default class MetadataEditorV extends Vue { this.metadata.contextLabel = config.contextLabel; this.metadata.dateModified = config.dateModified; - // Conversion for individual image panels to slideshow for gallery display this.slides = config.slides; - this.slides.forEach((slide: Slide) => { - if (slide.panel.length === 2 && slide.panel[1].type === 'image') { - const newSlide = { - type: 'slideshow', - images: [slide.panel[1]] - }; - slide.panel[1] = newSlide; - } - }); const logo = config.introSlide.logo?.src; if (logo) { diff --git a/src/components/editor/slide-editor.vue b/src/components/editor/slide-editor.vue index 67972de4..624da96e 100644 --- a/src/components/editor/slide-editor.vue +++ b/src/components/editor/slide-editor.vue @@ -28,7 +28,7 @@ type="checkbox" class="rounded-none cursor-pointer w-4 h-4" v-model="rightOnly" - :disabled="rightOnly && currentSlide.panel[panelIndex].type === 'dynamic'" + :disabled="rightOnly && determineEditorType(currentSlide.panel[panelIndex]) === 'dynamic'" @change.stop="$vfm.open(`right-only-${slideIndex}`)" /> @@ -189,14 +189,12 @@ $vfm.open(`change-slide-${slideIndex}`); newType = $event.target.value; " - :value="currentSlide.panel[panelIndex].type" + :value="determineEditorType(currentSlide.panel[panelIndex])" > @@ -205,8 +203,8 @@ = { text: 'text-editor', image: 'image-editor', - slideshow: 'image-editor', + slideshow: 'slideshow-editor', chart: 'chart-editor', map: 'map-editor', loading: 'loading-page', @@ -323,11 +323,16 @@ export default class SlideEditorV extends Vue { }, slideshow: { type: PanelType.Slideshow, - images: [] + items: [], + userCreated: true + }, + image: { + type: PanelType.Image, + src: '' }, chart: { type: PanelType.Chart, - charts: [] + src: '' }, map: { type: PanelType.Map, @@ -364,24 +369,30 @@ export default class SlideEditorV extends Vue { break; } + case 'image': { + const imagePanel = panel as ImagePanel; + this.sourceCounts[imagePanel.src] -= 1; + if (this.sourceCounts[imagePanel.src] === 0) { + this.configFileStructure.zip.remove(`${imagePanel.src.substring(imagePanel.src.indexOf('/') + 1)}`); + } + + break; + } + case 'chart': { const chartPanel = panel as ChartPanel; - chartPanel.charts.forEach((chart: ChartConfig) => { - this.sourceCounts[chart.src] -= 1; - if (this.sourceCounts[chart.src] === 0) { - this.configFileStructure.zip.remove(`${chart.src.substring(chart.src.indexOf('/') + 1)}`); - } - }); + this.sourceCounts[chartPanel.src] -= 1; + if (this.sourceCounts[chartPanel.src] === 0) { + this.configFileStructure.zip.remove(`${chartPanel.src.substring(chartPanel.src.indexOf('/') + 1)}`); + } + break; } case 'slideshow': { const slideshowPanel = panel as SlideshowPanel; - slideshowPanel.images.forEach((image: ImagePanel) => { - this.sourceCounts[image.src] -= 1; - if (this.sourceCounts[image.src] === 0) { - this.configFileStructure.zip.remove(`${image.src.substring(image.src.indexOf('/') + 1)}`); - } + slideshowPanel.items.forEach((item: TextPanel | ImagePanel | MapPanel | ChartPanel) => { + this.removeSourceCounts(item); }); break; } @@ -393,6 +404,10 @@ export default class SlideEditorV extends Vue { }); break; } + + case 'text': { + break; + } } } @@ -410,7 +425,26 @@ export default class SlideEditorV extends Vue { } cancelTypeChange(): void { - (this.$refs.typeSelector as HTMLSelectElement).value = this.currentSlide.panel[this.panelIndex].type; + (this.$refs.typeSelector as HTMLSelectElement).value = this.determineEditorType( + this.currentSlide.panel[this.panelIndex] + ); + } + + determineEditorType(panel: BasePanel): string { + if (panel.type !== PanelType.Slideshow) return panel.type; + if ((panel as SlideshowPanel).items.length === 0 || (panel as SlideshowPanel).userCreated) + return PanelType.Slideshow; + + // Determine whether the slideshow consists of only charts. If so, display the chart editor. + const allCharts = (panel as SlideshowPanel).items.every((item: BasePanel) => item.type === PanelType.Chart); + if (allCharts) return PanelType.Chart; + + // Determine whether the slideshow consists of only images. If so, display the image editor. + const allImages = (panel as SlideshowPanel).items.every((item: BasePanel) => item.type === PanelType.Image); + if (allImages) return PanelType.Image; + + // Otherwise display the slideshow editor. + return PanelType.Slideshow; } toggleRightOnly(): void { diff --git a/src/components/editor/slide-toc.vue b/src/components/editor/slide-toc.vue index 94392d61..3e67e233 100644 --- a/src/components/editor/slide-toc.vue +++ b/src/components/editor/slide-toc.vue @@ -251,24 +251,30 @@ export default class SlideTocV extends Vue { break; } + case 'image': { + const imagePanel = panel as ImagePanel; + this.sourceCounts[imagePanel.src] -= 1; + if (this.sourceCounts[imagePanel.src] === 0) { + this.configFileStructure.zip.remove(`${imagePanel.src.substring(imagePanel.src.indexOf('/') + 1)}`); + } + + break; + } + case 'chart': { const chartPanel = panel as ChartPanel; - chartPanel.charts.forEach((chart: ChartConfig) => { - this.sourceCounts[chart.src] -= 1; - if (this.sourceCounts[chart.src] === 0) { - this.configFileStructure.zip.remove(`${chart.src.substring(chart.src.indexOf('/') + 1)}`); - } - }); + this.sourceCounts[chartPanel.src] -= 1; + if (this.sourceCounts[chartPanel.src] === 0) { + this.configFileStructure.zip.remove(`${chartPanel.src.substring(chartPanel.src.indexOf('/') + 1)}`); + } + break; } case 'slideshow': { const slideshowPanel = panel as SlideshowPanel; - slideshowPanel.images.forEach((image: ImagePanel) => { - this.sourceCounts[image.src] -= 1; - if (this.sourceCounts[image.src] === 0) { - this.configFileStructure.zip.remove(`${image.src.substring(image.src.indexOf('/') + 1)}`); - } + slideshowPanel.items.forEach((item: TextPanel | MapPanel | ChartPanel | ImagePanel) => { + this.removeSourceHelper(item); }); break; } @@ -280,6 +286,10 @@ export default class SlideTocV extends Vue { }); break; } + + case 'text': { + break; + } } } diff --git a/src/components/editor/slideshow-editor.vue b/src/components/editor/slideshow-editor.vue new file mode 100644 index 00000000..48666d11 --- /dev/null +++ b/src/components/editor/slideshow-editor.vue @@ -0,0 +1,309 @@ + + + + + diff --git a/src/definitions.ts b/src/definitions.ts index d320b15c..8c2a49e8 100644 --- a/src/definitions.ts +++ b/src/definitions.ts @@ -190,12 +190,9 @@ export interface ImagePanel extends BasePanel { src: string; width?: number; height?: number; - temp?: string; - class?: string; fullscreen?: boolean; altText?: string; caption?: string; - tooltip?: string; } export interface VideoPanel extends BasePanel { @@ -214,16 +211,19 @@ export interface AudioPanel extends BasePanel { export interface SlideshowPanel extends BasePanel { type: PanelType.Slideshow; - images: ImagePanel[]; - fullscreen?: boolean; + items: Array; loop?: boolean; caption?: string; + userCreated?: boolean; // used to determine whether this was automatically converted to slideshow or not } export interface ChartPanel extends BasePanel { type: PanelType.Chart; - charts: ChartConfig[]; - fullscreen?: boolean; + src: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + config?: any; + name?: string; + options?: DQVOptions; } export interface ChartConfig { @@ -249,4 +249,5 @@ export interface DefaultConfigs { chart: ChartPanel; dynamic: DynamicPanel; map: MapPanel; + image: ImagePanel; } diff --git a/src/lang/lang.csv b/src/lang/lang.csv index f564e256..a29770b5 100644 --- a/src/lang/lang.csv +++ b/src/lang/lang.csv @@ -71,7 +71,7 @@ editor.chart.label.name,Name,1,Nom,1 editor.chart.label.edit,Edit,1,Éditer,1 editor.chart.label.empty,Empty,1,Vide,1 editor.chart.label.create,Add new chart,1,Ajouter un nouveau graphique,1 -editor.chart.label.info,Interactive charts ({num}),1,Graphiques interactifs ({num}),1 +editor.chart.label.info,Interactive chart,1,[FR] Interactive chart,0 editor.chart.delete.confirm,Are you sure you want to delete the chart {name}?,1,Voulez-vous vraiment supprimer le graphique {nom}?,1 editor.map.title,Map title,1,Titre de la carte,1 editor.map.edit.cancel,Cancel editing,1,Annuler les modifications,1 @@ -91,6 +91,11 @@ editor.map.timeslider.warning,⚠️Warning! Please ensure that:,1,⚠️Avertis editor.map.timeslider.warning.bullet1,All range and start values are positive integers.,1,Toutes les valeurs de la tranche et du début sont des nombres entiers positifs.,1 editor.map.timeslider.warning.bullet2,The "to" value is greater than or equal to the "from" value.,1,La valeur « À » est supérieure ou égale à la valeur « De ».,1 editor.map.timeslider.warning.end,"Otherwise, your time slider config cannot be saved.",1,Autrement, la configuration du curseur temporel ne pourra pas être enregistrée.,1 +editor.slideshow.label.info,Slideshow items ({num}),1,[FR] Slideshow items ({num}),0 +editor.slideshow.label.create,Add new item,1,[FR] Add new item,0 +editor.slideshow.label.edit,Edit existing item,1,[FR] Edit existing item,0 +editor.slideshow.label.type,Type,1,[FR] Type,0 +editor.slideshow.label.add,Add,1,[FR] Add,0 editor.slides.title,SLIDES,1,DIAPOSITIVES,1 editor.slides.addSlide,"New Slide",1,Nouvelle diapositive,1 editor.slides.copyFromLang,"Copy slides from the other language",1,"Copier les diapositives de l’autre langue",1