From 43899dd1f481e526ba21b180e0706b592dd193ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Carlos=20Dias?= Date: Mon, 19 Jul 2021 10:43:14 +0200 Subject: [PATCH 01/13] Initial European Portuguese Localization --- public/locales/pt-pt.json | 438 +++++++++++++++++++++++++++++--------- 1 file changed, 334 insertions(+), 104 deletions(-) diff --git a/public/locales/pt-pt.json b/public/locales/pt-pt.json index 664dc9ae3..e4f88e985 100644 --- a/public/locales/pt-pt.json +++ b/public/locales/pt-pt.json @@ -1,106 +1,336 @@ { - "colors": {}, - "common": { - "add": "Adicionar", - "appName": "Twine", - "cancel": "Cancelar", - "delete": "Apagar", - "duplicate": "Duplicar", - "edit": "Editar", - "ok": "OK", - "play": "Jogar", - "rename": "Mudar o nome", - "remove": "Remover", - "skip": "Saltar", - "storyFormat": "Formato de história", - "tag": "Etiqueta", - "test": "Testar", - "undo": "Desfazer" - }, - "components": { - "addStoryFormatButton": {}, - "addTagButton": {}, - "fontSelect": {"fonts": {}}, - "indentButtons": {}, - "localStorageQuota": {}, - "renamePassageButton": {"emptyName": "Indique um nome, por favor."}, - "renameStoryButton": {"emptyName": "Indique um nome, por favor."}, - "safariWarningCard": {}, - "storyCard": {}, - "storyFormatCard": {}, - "storyFormatSelect": {}, - "tagEditor": {} - }, - "dialogs": { - "aboutTwine": {"donateToTwine": "Ajude o Twine a crescer com uma doação"}, - "appDonation": {"noThanks": "Não, obrigado"}, - "appPrefs": {"language": "Idioma"}, - "passageEdit": {}, - "passageTags": {}, - "storyInfo": {"stats": {"title": "Estatísticas da história"}}, - "storyJavaScript": { - "explanation": "Qualquer código de JavaScript aqui introduzido vai correr assim que a história for aberta no navegador." - }, - "storySearch": { - "title": "Encontrar e Substituir", - "replaceWith": "Substituir por" - }, - "storyStylesheet": { - "explanation": "Qualquer fragmento de código CSS introduzido aqui irá alterar a aparência padrão da sua história." - }, - "storyTags": {} - }, - "electron": { - "errors": {"storyFileChangedExternally": {}}, - "menuBar": {"edit": "Editar"}, - "storiesDirectoryName": "Histórias" - }, - "routes": { - "storyEdit": { - "topBar": { - "addPassage": "Passagem", - "editJavaScript": "Editar o código JavaScript da história", - "editStylesheet": "Editar a folha de estilos da história", - "findAndReplace": "Encontrar e Substituir", - "proofStory": "Ver uma cópia para revisão", - "publishToFile": "Publicar como ficheiro" - } - }, - "storyFormatList": { - "title": {}, - "storyFormatExplanation": "Os formatos de história controlam a aparência e o comportamento das histórias durante o jogo." - }, - "storyImport": {}, - "storyList": { - "noStories": "Neste momento, não há histórias gravadas no Twine. Para começar, crie uma nova história ou importe uma de um ficheiro.", - "titleGeneric": "Histórias", - "topBar": { - "about": "Sobre o Twine", - "archive": "Arquivo", - "createStory": "História", - "help": "Ajuda", - "sortName": "Nome", - "storyFormats": "Formatos" - } - }, - "welcome": { - "autosaveTitle": "O seu trabalho é guardado automaticamente.", - "doneTitle": "E é isto!", - "gotoStoryList": "Ir para a Lista de Histórias", - "greetingTitle": "Olá!", - "tellMeMore": "Quero saber mais", - "helpTitle": "É a primeira vez aqui?" - } - }, - "store": { - "errors": {}, - "passageDefaults": { - "name": "Passagem sem título", - "textClick": "Faça duplo-clique nesta passagem para editá-la.", - "textTouch": "Toque nesta passagem e, em seguida, no ícone do lápis para editá-la." - }, - "storyDefaults": {"name": "História sem título"}, - "storyFormatDefaults": {"name": "Formato de história sem título"} - }, - "undoChange": {"replaceAllText": "Substituir tudo"} + "colors": { + "blue": "Azul", + "green": "Verde", + "none": "Sem cor", + "orange": "Laranja", + "purple": "Roxo", + "red": "Vermelho", + "yellow": "Amarelo" + }, + "common": { + "add": "Adicionar", + "appName": "Twine", + "cancel": "Cancelar", + "close": "Fechar", + "color": "Cor", + "custom": "Personalizado", + "delete": "Apagar", + "deleteCount": "Apagar ({{count}})", + "duplicate": "Duplicar", + "edit": "Editar", + "editCount": "Editar ( {{count}} )", + "import": "Importar", + "more": "Mais", + "next": "Próximo", + "ok": "OK", + "play": "Jogar", + "preferences": "Preferências", + "publishToFile": "Publicar como ficheiro", + "redo": "Refazer", + "redoChange": "Refazer {{change}}", + "remove": "Remover", + "rename": "Mudar o nome", + "renamePrompt": "Que novo nome queres dar a “{{name}}”?", + "skip": "Saltar", + "storyFormat": "Formato de história", + "tag": "Etiqueta", + "test": "Testar", + "undo": "Desfazer", + "undoChange": "Desfazer {{change}}" + }, + "components": { + "addStoryFormatButton": { + "addPreview": "O {{storyFormatName}} {{storyFormatVersion}} vai ser adicionado.", + "alreadyAdded": "O {{storyFormatName}} {{storyFormatVersion}} já foi adicionado.", + "fetchError": "Ocorreu um erro ao transferir este formato ( {{errorMessage}} ).", + "invalidUrl": "O endereço que introduziste não é um URL válido.", + "prompt": "Para adicionares um formato de história, introduz o seu endereço em baixo." + }, + "addTagButton": { + "addLabel": "Adicionar etiqueta", + "alreadyAdded": "Esta etiqueta já foi adicionada.", + "invalidName": "Por favor, introduz um nome válido para a etiqueta.", + "newTag": "Nova Etiqueta", + "tagColorLabel": "Cor da etiqueta", + "tagNameLabel": "Nome da etiqueta" + }, + "fontSelect": { + "customFamilyDetail": "Por favor, introduz apenas o nome da fonte.", + "customScaleDetail": "Por favor, introduz apenas uma percentagem.", + "familyEmpty": "Por favor, introduz um nome de fonte.", + "font": "Fonte", + "fontSize": "Tamanho da Fonte", + "fonts": { + "monospaced": "Monoespaçado", + "serif": "Serifa", + "system": "Sistema" + }, + "percentage": "{{percent}}%", + "percentageIsntNumber": "Por favor, introduz um número.", + "percentageNotPositive": "Introduz, por favor, um número maior do que 0." + }, + "indentButtons": { + "indent": "Indentar", + "unindent": "Retirar indentação" + }, + "localStorageQuota": { + "measureAgain": "Avaliar o espaço disponível novamente", + "percentAvailable": "{percent}% de espaço disponível" + }, + "renamePassageButton": { + "emptyName": "Indica um nome, por favor.", + "nameAlreadyUsed": "A história já tem uma passagem com esse nome." + }, + "renameStoryButton": { + "emptyName": "Indica um nome, por favor.", + "nameAlreadyUsed": "Já há uma história com esse nome." + }, + "safariWarningCard": { + "addToHomeScreen": "Adiciona esta página ao teu ecrã inicial para evitares esta limitação.", + "archiveAndUseAnotherBrowser": "Arquiva as tuas histórias e usa outra plataforma, por favor.", + "howToAddToHomeScreen": "Como é que eu adiciono isto ao meu ecrã inicial?", + "learnMore": "Saber mais", + "message": "O navegador que estás a usar vai apagar todas as tuas histórias se passares sete dias sem visitar este site." + }, + "storyCard": { + "lastUpdated": "Última edição em {{date}}", + "passageCount": "1 passagem", + "passageCount_plural": "{{count}} passagens" + }, + "storyFormatCard": { + "author": "por {{author}}", + "license": "Licença: {{license}}", + "loadError": "Este formato de história não pôde ser carregado ( {{errorMessage}} ).", + "loadingFormat": "A carregar este formato de história...", + "name": "{{version}} {{name}}", + "useFormat": "Usar como formato de história por defeito", + "useProofingFormat": "Usar como formato de revisão" + }, + "storyFormatSelect": { + "loadingCount": "A carregar 1 formato de história ...", + "loadingCount_plural": "A carregar {{loadingCount}} formatos de história..." + }, + "tagEditor": { + "alreadyExists": "Já existe uma etiqueta com esse nome." + } + }, + "dialogs": { + "aboutTwine": { + "codeHeader": "Código", + "codeRepo": "Visitar o repositório do código-fonte", + "donateToTwine": "Ajuda o Twine a crescer com uma doação", + "license": "Esta aplicação é lançada de acordo com a licença GPL v3 , mas qualquer trabalho criado com ela, pode ser lançado sob quaisquer termos, incluindo comerciais.", + "localizationHeader": "Localizações", + "title": "Sobre o {{version}}", + "twineDescription": "O Twine é uma aplicação de código-fonte aberto para contar histórias interativas e não lineares." + }, + "appDonation": { + "donate": "Doar para o desenvolvimento do Twine", + "noThanks": "Não, obrigado", + "onlyOnce": "(Esta mensagem só te será apresentada uma vez. Se quiseres fazer uma doação para o desenvolvimento do Twine, há um \"link\" na caixa de diálogo \"Sobre o Twine\".)", + "supportMessage": "Se não podes viver sem o Twine, talvez o possas ajudar a crescer, fazendo uma doação. O Twine é um projeto de código-fonte aberto que será sempre gratuito — e graças à tua ajuda, o Twine poderá continuar a prosperar.", + "title": "Apoiar o desenvolvimento do Twine" + }, + "appPrefs": { + "codeEditorFont": "Fonte do editor de código", + "codeEditorFontScale": "Tamanho da fonte do editor de código", + "fontExplanation": "Alterar a fonte aqui, afeta apenas o editor do Twine. A fonte usada na história não será alterada.", + "language": "Idioma", + "passageEditorFont": "Fonte do editor de passagem", + "passageEditorFontScale": "Tamanho da fonte do editor de passagens", + "theme": "Tema", + "themeDark": "Escuro", + "themeLight": "Claro", + "themeSystem": "Sistema", + "title": "Preferências" + }, + "passageEdit": { + "setAsStart": "Começar a história aqui", + "size": "Tamanho", + "sizeLarge": "Larga", + "sizeSmall": "Pequeno", + "sizeTall": "Estreita", + "sizeWide": "Larga" + }, + "passageTags": { + "noTags": "Não foram adicionadas etiquetas às passagens desta história.", + "title": "Etiquetas da passagem" + }, + "storyInfo": { + "setStoryFormat": "Definir o formato de história", + "snapToGrid": "Ajustar à grelha", + "stats": { + "brokenLinks": "Links quebrados", + "characters": "Personagens", + "ifid": "O IFID desta história é {{ifid}}.", + "ifidExplanation": "O que é um IFID?", + "lastUpdate": "Esta história foi alterada pela última vez em {{date}} .", + "links": "Links", + "passages": "Passagens", + "title": "Estatísticas da história", + "words": "Palavras" + }, + "storyFormatExplanation": "O que é um formato de história?" + }, + "storyJavaScript": { + "explanation": "Qualquer código de JavaScript aqui introduzido vai correr assim que a história for aberta no navegador.", + "title": "JavaScript da História" + }, + "storySearch": { + "find": "Encontrar", + "includePassageNames": "Incluir os nomes das passagens", + "matchCase": "Sensível a maiúsculas/minúsculas", + "matchCount": "{{count}} passagem correspondente", + "matchCount_plural": "{{count}} passagens correspondentes", + "noMatches": "Sem passagens correspondentes", + "replaceAll": "Substituir em todas as passagens", + "replaceWith": "Substituir por", + "title": "Encontrar e Substituir", + "useRegexes": "Usar expressões regulares" + }, + "storyStylesheet": { + "explanation": "Qualquer fragmento de código CSS introduzido aqui irá alterar a aparência padrão da sua história.", + "title": "Folha de estilos da história" + }, + "storyTags": { + "noTags": "Não foram adicionadas etiquetas às tuas histórias.", + "title": "Etiquetas de história" + } + }, + "electron": { + "backupsDirectoryName": "Cópias de segurança", + "errors": { + "jsonSave": "Ocorreu um erro ao gravar o ficheiro de configurações.", + "storyDelete": "Ocorreu um erro ao apagar a história.", + "storyFileChangedExternally": { + "detail": "As alterações vão ser gravadas por cima deste ficheiro. Se quiseres usar este ficheiro em vez da versão que está no Twine, o Twine vai reiniciar, e teu trabalho será substituído pelo do ficheiro.", + "message": "O ficheiro “ {{fileName}} ” da tua biblioteca de histórias foi alterado fora do Twine.", + "overwriteChoice": "Gravar alterações no Twine", + "relaunchChoice": "Usar o ficheiro e reiniciar" + }, + "storyRename": "Ocorreu um erro ao renomear a história.", + "storySave": "Ocorreu um erro ao gravar a história." + }, + "menuBar": { + "edit": "Editar", + "showDevTools": "Mostrar consola de depuração", + "showStoryLibrary": "Mostrar a biblioteca de histórias", + "speech": "Fala", + "troubleshooting": "Solução de problemas", + "twineHelp": "Ajuda do Twine", + "view": "Visualizar" + }, + "storiesDirectoryName": "Histórias" + }, + "routes": { + "storyEdit": { + "topBar": { + "addPassage": "Passagem", + "editJavaScript": "Editar o código JavaScript da história", + "editStylesheet": "Editar a folha de estilos da história", + "findAndReplace": "Encontrar e substituir", + "passageTags": "Editar Etiquetas da Passagem", + "proofStory": "Ver uma cópia para revisão", + "publishToFile": "Publicar como ficheiro", + "selectAllPassages": "Marcar todas as passagens", + "storyInfo": "Informações sobre a história", + "zoomIn": "Aumentar zoom", + "zoomOut": "Reduzir o zoom" + } + }, + "storyFormatList": { + "noneVisible": "Nenhum formato de história corresponde aos critérios que selecionaste.", + "show": "Mostrar...", + "storyFormatExplanation": "Os formatos de história controlam a aparência e o comportamento das histórias durante o jogo.", + "title": { + "all": "Todos os formatos de história", + "current": "Formatos de história disponíveis", + "user": "Formatos de história adicionados pelo utilizador" + } + }, + "storyImport": { + "choosePrompt": "Escolhe as histórias que queres importar do arquivo que carregaste:", + "deselectAll": "Desmarcar tudo", + "importDifferentFile": "Importar um ficheiro diferente", + "importSelected": "Importar os ficheiros selecionados", + "importThisStory": "Importar esta história", + "noStoriesInFile": "Não encontrámos nenhumas histórias do Twine no ficheiro que carregaste. Tenta, por favor, um outro ficheiro.", + "selectAll": "Marcar tudo", + "title": "Importar histórias", + "uploadPrompt": "Para importar histórias para o Twine, carrega, em baixo, um ficheiro de arquivo de histórias ou um ficheiro de uma história.", + "willReplaceExisting": "A história da tua biblioteca com esse mesmo nome será substituída." + }, + "storyList": { + "noStories": "Neste momento, não há histórias gravadas no Twine. Para começares, cria uma história nova ou importa uma de um ficheiro.", + "taggedTitleCount": "1 história etiquetada", + "taggedTitleCount_0": "Sem histórias etiquetadas", + "taggedTitleCount_plural": "{{count}} histórias etiquetadas", + "titleCount": "1 história", + "titleCount_0": "Sem histórias", + "titleCount_plural": "{{count}} Histórias", + "titleGeneric": "Histórias", + "topBar": { + "about": "Sobre o Twine", + "archive": "Arquivo", + "createStory": "História", + "help": "Ajuda", + "reportBug": "Reportar um erro", + "showAllStories": "Todas as histórias", + "showTags": "Mostrar etiquetas...", + "sort": "Ordenar por...", + "sortDate": "Data", + "sortName": "Nome", + "storyFormats": "Formatos", + "storyTags": "Editar as etiquetas da história" + } + }, + "welcome": { + "autosave": "

Agora já tens uma pasta chamada Twine na tua pasta de Documentos. Dentro da pasta Twine, criámos uma pasta chamada Histórias, onde todos os teus trabalhos serão gravados. O Twine grava automaticamente enquanto trabalhas, portanto não precisas de te preocupar em gravar a tua história. Podes sempre abrir a pasta em que as tuas histórias estão gravadas através da opção Mostrar biblioteca no menu do Twine.

Como o Twine está sempre a gravar o teu trabalho, os ficheiros da tua biblioteca de histórias não podem ser editados enquanto o Twine estiver aberto.

Se quiseres abrir o ficheiro de uma história do Twine que recebeste de uma outra pessoa, podes importá-lo para a tua biblioteca usando a opção Importar ficheiro na lista de histórias.

", + "autosaveTitle": "O teu trabalho é gravado automaticamente.", + "browserStorage": "

Isto significa que não precisas de criar uma conta para usar o Twine 2, e tudo o que criares não fica armazenado num servidor sabe-se lá onde — fica aqui no teu navegador.

No entanto, há duas coisas muito importantes de que tens de te lembrar. Como o teu trabalho fica apenas gravado no teu navegador, se limpares os dados do navegador, vais perder o teu trabalho! O que não é nada bom. Lembra-te de usar o botão Arquivar com frequência. Também podes publicar cada história separadamente num ficheiro, usando a opção disponível em cada história, na lista de histórias. Tanto os ficheiros de arquivo, como os ficheiros individuais de história podem ser reimportados, a qualquer momento, para o Twine.

Em segundo lugar, qualquer pessoa que usar este navegador, pode ver e fazer alterações ao teu trabalho. Portanto, se tiveres um irmão mais novo abelhudo, talvez seja boa ideia criares um perfil separado só para ti.

", + "browserStorageTitle": "O teu trabalho fica apenas gravado no navegador", + "done": "

Obrigado por leres, e diverte-te com o Twine.

", + "doneTitle": "E é isto!", + "gotoStoryList": "Ir para a lista de histórias", + "greeting": "

O Twine é uma ferramenta de código-fonte aberto para contar histórias interativas e não lineares. Há algumas coisas que é importante saberes antes de começar.

", + "greetingTitle": "Olá!", + "help": "

Se nunca usaste o Twine antes, bem-vinda(o)! O O Livro de Receitas do Twine é um ótimo recurso para aprenderes a usar o programa. Se nunca usaste o Twine, é um ótimo lugar para começar.

", + "helpTitle": "É a primeira vez aqui?", + "tellMeMore": "Quero saber mais" + } + }, + "store": { + "archiveFilename": "{{timestamp}} Arquivo_do_Twine.html", + "errors": { + "cantPersistPrefs": "Erro ao gravar as preferências ( {{error}} ).", + "cantPersistStories": "Erro ao gravar as tuas histórias ( {{error}} ).", + "cantPersistStoryFormats": "Erro ao gravar os formatos de história ( {{error}} ).", + "electronRemediation": "Reiniciar a aplicação poderá ajudar.", + "webRemediation": "Recarregar esta página poderá ajudar." + }, + "passageDefaults": { + "name": "Passagem sem título", + "textClick": "Faz duplo-clique nesta passagem para editá-la.", + "textTouch": "Toca nesta passagem e, em seguida, no ícone do lápis para editá-la." + }, + "storyDefaults": { + "name": "História sem título" + }, + "storyFormatDefaults": { + "name": "Formato de história sem título" + } + }, + "undoChange": { + "changeTagColor": "Mudar a cor da etiqueta", + "createPassage": "Criar passagem", + "deletePassage": "Apagar passagem", + "deletePassages": "Apagar passagens", + "movePassage": "Mover passagem", + "movePassages": "Mover passagens", + "removeTag": "Remover etiqueta", + "renamePassage": "Renomear passagem", + "renameTag": "Renomear etiqueta", + "replaceAllText": "Substituir tudo" + } } From dc2cd30f9d2cfae4f03d7b749adb97bc78c32c32 Mon Sep 17 00:00:00 2001 From: Chris Klimas Date: Mon, 19 Jul 2021 13:53:28 -0400 Subject: [PATCH 02/13] Remove unneeded imports --- .github/workflows/create-release.yml | 4 +--- .../story-format/story-format-card/story-format-card.tsx | 6 +++++- src/routes/story-format-list/story-format-list-route.tsx | 9 +++++++-- src/routes/story-import/story-import-list.tsx | 1 - src/routes/story-list/story-cards.tsx | 1 - .../persistence/local-storage/stories/save-middleware.ts | 1 - .../story-formats/__tests__/action-creators.test.ts | 9 +++++++++ src/store/story-formats/action-creators.ts | 4 ++++ 8 files changed, 26 insertions(+), 9 deletions(-) diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 90d6cb815..0be0fcb7d 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -20,10 +20,8 @@ jobs: run: npm install - name: Test run: npm run test - # Disable the CI flag to allow warnings in the build. - # https://github.com/facebook/create-react-app/issues/3657 - name: Build - run: CI=false npm run build + run: npm run build - name: Create draft release uses: softprops/action-gh-release@v1 with: diff --git a/src/components/story-format/story-format-card/story-format-card.tsx b/src/components/story-format/story-format-card/story-format-card.tsx index 1bb0ee3e6..ce8477895 100644 --- a/src/components/story-format/story-format-card/story-format-card.tsx +++ b/src/components/story-format/story-format-card/story-format-card.tsx @@ -64,7 +64,11 @@ export const StoryFormatCard: React.FC = props => { /> )} {format.userAdded && ( - } label={t('common.delete')} /> + } + label={t('common.delete')} + onClick={onDelete} + /> )} diff --git a/src/routes/story-format-list/story-format-list-route.tsx b/src/routes/story-format-list/story-format-list-route.tsx index 8385a5b43..c6e5a1d1c 100644 --- a/src/routes/story-format-list/story-format-list-route.tsx +++ b/src/routes/story-format-list/story-format-list-route.tsx @@ -17,7 +17,8 @@ import { sortFormats, useStoryFormatsContext, StoryFormat, - StoryFormatProperties + StoryFormatProperties, + deleteFormat } from '../../store/story-formats'; export const StoryFormatListRoute: React.FC = () => { @@ -37,6 +38,10 @@ export const StoryFormatListRoute: React.FC = () => { prefsDispatch({type: 'update', name: 'storyFormatListFilter', value}); } + function handleDeleteFormat(format: StoryFormat) { + formatsDispatch(deleteFormat(format)); + } + function handleSelect(format: StoryFormat) { if (format.loadState !== 'loaded') { throw new Error("Can't select an unloaded format"); @@ -114,7 +119,7 @@ export const StoryFormatListRoute: React.FC = () => { {}} + onDelete={() => handleDeleteFormat(format)} onSelect={() => handleSelect(format)} selected={ (format.name === prefs.storyFormat.name && diff --git a/src/routes/story-import/story-import-list.tsx b/src/routes/story-import/story-import-list.tsx index 6162c1130..5d53a56ef 100644 --- a/src/routes/story-import/story-import-list.tsx +++ b/src/routes/story-import/story-import-list.tsx @@ -2,7 +2,6 @@ import * as React from 'react'; import {useTranslation} from 'react-i18next'; import {StoryImportCard} from '../../components/story/story-import-card'; import {CardGroup} from '../../components/container/card-group'; -import {IconButton} from '../../components/control/icon-button'; import {Story} from '../../store/stories'; /** diff --git a/src/routes/story-list/story-cards.tsx b/src/routes/story-list/story-cards.tsx index f2f45cfbd..283eb5571 100644 --- a/src/routes/story-list/story-cards.tsx +++ b/src/routes/story-list/story-cards.tsx @@ -6,7 +6,6 @@ import {setPref, usePrefsContext} from '../../store/prefs'; import { deleteStory, duplicateStory, - renameStoryTag, updateStory, Story } from '../../store/stories'; diff --git a/src/store/persistence/local-storage/stories/save-middleware.ts b/src/store/persistence/local-storage/stories/save-middleware.ts index 113d4722e..64b8dc7fd 100644 --- a/src/store/persistence/local-storage/stories/save-middleware.ts +++ b/src/store/persistence/local-storage/stories/save-middleware.ts @@ -7,7 +7,6 @@ import { storyWithId, storyWithName } from '../../../stories'; -import {StoryFormatsState} from '../../../story-formats'; import { deletePassageById, deleteStory, diff --git a/src/store/story-formats/__tests__/action-creators.test.ts b/src/store/story-formats/__tests__/action-creators.test.ts index d83d78b82..3b83add77 100644 --- a/src/store/story-formats/__tests__/action-creators.test.ts +++ b/src/store/story-formats/__tests__/action-creators.test.ts @@ -1,5 +1,6 @@ import { createFromProperties, + deleteFormat, loadAllFormatProperties, loadFormatProperties } from '../action-creators'; @@ -47,6 +48,14 @@ describe('createFromProperties', () => { }); }); +describe('deleteFormat', () => { + it('returns a delete action', () => { + const format = fakePendingStoryFormat(); + + expect(deleteFormat(format)).toEqual({type: 'delete', id: format.id}); + }); +}); + describe('loadAllFormatProperties', () => { let dispatch: jest.Mock; let formats: StoryFormat[]; diff --git a/src/store/story-formats/action-creators.ts b/src/store/story-formats/action-creators.ts index a62b1172e..58d79e2da 100644 --- a/src/store/story-formats/action-creators.ts +++ b/src/store/story-formats/action-creators.ts @@ -26,6 +26,10 @@ export function createFromProperties( }; } +export function deleteFormat(format: StoryFormat): StoryFormatsAction { + return {type: 'delete', id: format.id}; +} + async function loadFormatThunk( format: StoryFormat, dispatch: StoryFormatsDispatch From 1682f6128bc81776116803b15322f6cb29d225b0 Mon Sep 17 00:00:00 2001 From: Chris Klimas Date: Mon, 19 Jul 2021 18:45:39 -0400 Subject: [PATCH 03/13] Tweak release workflow --- .github/workflows/create-release.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 0be0fcb7d..b86efe6d3 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -2,10 +2,9 @@ name: Create draft release on: push: - branches-ignore: - - '2.3-maintenance' tags: - '[0-9]+.[0-9]+.[0-9]+*' + - '!2.3.*' jobs: build: From bf24b58935bbbfce8ea6bcbc54da36b5646affac Mon Sep 17 00:00:00 2001 From: albuquezi Date: Tue, 20 Jul 2021 09:59:38 +0200 Subject: [PATCH 04/13] Update public/locales/pt-pt.json Co-authored-by: Chris Klimas <1405578+klembot@users.noreply.github.com> --- public/locales/pt-pt.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/locales/pt-pt.json b/public/locales/pt-pt.json index e4f88e985..6fb69c211 100644 --- a/public/locales/pt-pt.json +++ b/public/locales/pt-pt.json @@ -19,7 +19,7 @@ "deleteCount": "Apagar ({{count}})", "duplicate": "Duplicar", "edit": "Editar", - "editCount": "Editar ( {{count}} )", + "editCount": "Editar ({{count}})", "import": "Importar", "more": "Mais", "next": "Próximo", From a553a67a9d099f78d0220dca8403dbb785a1c85c Mon Sep 17 00:00:00 2001 From: Chris Klimas Date: Sun, 25 Jul 2021 18:12:17 -0400 Subject: [PATCH 05/13] Update credits after PR --- src/dialogs/about-twine/credits.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dialogs/about-twine/credits.json b/src/dialogs/about-twine/credits.json index cc19fef5b..1f5afe82c 100644 --- a/src/dialogs/about-twine/credits.json +++ b/src/dialogs/about-twine/credits.json @@ -19,7 +19,7 @@ "Valentin Rocher (Français)", "Marco Secchi (Italiano)", "Stefan Peeters (Nederlands)", - "José Dias (Português)", + "José Carlos Dias (Português)", "Alliah (Português Brasileiro)", "Anton Zhuchkov (Русский)", "Mika Letonsaari (Suomi)", From 9c82facbc863d4d767f16a63482c5301abc07fd1 Mon Sep 17 00:00:00 2001 From: Chris Klimas Date: Sun, 25 Jul 2021 18:13:54 -0400 Subject: [PATCH 06/13] Update story when format menu changes --- src/dialogs/story-info/story-info.tsx | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/dialogs/story-info/story-info.tsx b/src/dialogs/story-info/story-info.tsx index 3e594f3dc..eb3e33b1e 100644 --- a/src/dialogs/story-info/story-info.tsx +++ b/src/dialogs/story-info/story-info.tsx @@ -7,6 +7,7 @@ import {DialogCard} from '../../components/container/dialog-card'; import {StoryFormatSelect} from '../../components/story-format/story-format-select'; import {storyWithId, updateStory, useStoriesContext} from '../../store/stories'; import { + formatWithId, formatWithNameAndVersion, useStoryFormatsContext } from '../../store/story-formats'; @@ -26,6 +27,17 @@ export const StoryInfoDialog: React.FC = props => { const story = storyWithId(stories, storyId); const {t} = useTranslation(); + function handleFormatChange(event: React.ChangeEvent) { + const newFormat = formatWithId(formats, event.target.value); + + dispatch( + updateStory(stories, story, { + storyFormat: newFormat.name, + storyFormatVersion: newFormat.version + }) + ); + } + return ( = props => { Date: Mon, 26 Jul 2021 22:22:36 -0400 Subject: [PATCH 07/13] Add story format CodeMirror mode support --- .../control/code-area/codemirror-theme.css | 2 +- .../story-format/add-story-format-button.tsx | 2 +- src/dialogs/passage-edit/passage-edit.tsx | 11 ++ src/dialogs/passage-edit/passage-text.tsx | 25 +++- .../stories/__tests__/save-story.test.ts | 2 +- .../electron-ipc/stories/save-story.ts | 2 +- .../__tests__/action-creators.test.ts | 63 ++++++++- .../use-format-codemirror-mode.test.ts | 132 ++++++++++++++++++ src/store/story-formats/action-creators.ts | 27 +++- src/store/story-formats/index.ts | 1 + .../story-formats/story-formats.types.ts | 11 ++ .../use-format-codemirror-mode.ts | 50 +++++++ .../__tests__/editor-extensions.test.ts | 87 ++++++++++++ .../__tests__/fetch-properties.test.ts} | 8 +- src/util/story-format/editor-extensions.ts | 41 ++++++ .../fetch-properties.ts} | 6 +- 16 files changed, 452 insertions(+), 18 deletions(-) create mode 100644 src/store/story-formats/__tests__/use-format-codemirror-mode.test.ts create mode 100644 src/store/story-formats/use-format-codemirror-mode.ts create mode 100644 src/util/story-format/__tests__/editor-extensions.test.ts rename src/util/{__tests__/fetch-story-format-properties.test.ts => story-format/__tests__/fetch-properties.test.ts} (91%) create mode 100644 src/util/story-format/editor-extensions.ts rename src/util/{fetch-story-format-properties.ts => story-format/fetch-properties.ts} (89%) diff --git a/src/components/control/code-area/codemirror-theme.css b/src/components/control/code-area/codemirror-theme.css index aa9f2d4c2..0a0be1c9d 100644 --- a/src/components/control/code-area/codemirror-theme.css +++ b/src/components/control/code-area/codemirror-theme.css @@ -25,7 +25,7 @@ The goal here is to align CM's default theme with our color palette. font-style: italic; } .cm-link { - text-decoration: underline; + text-decoration: none; } .cm-strikethrough { text-decoration: line-through; diff --git a/src/components/story-format/add-story-format-button.tsx b/src/components/story-format/add-story-format-button.tsx index ec22bda34..3214e34f9 100644 --- a/src/components/story-format/add-story-format-button.tsx +++ b/src/components/story-format/add-story-format-button.tsx @@ -4,7 +4,7 @@ import {IconPlus} from '@tabler/icons'; import {PromptButton, PromptButtonValidator} from '../control/prompt-button'; import {StoryFormat, StoryFormatProperties} from '../../store/story-formats'; import './add-story-format-button.css'; -import {fetchStoryFormatProperties} from '../../util/fetch-story-format-properties'; +import {fetchStoryFormatProperties} from '../../util/story-format/fetch-properties'; export interface AddStoryFormatButtonProps { existingFormats: StoryFormat[]; diff --git a/src/dialogs/passage-edit/passage-edit.tsx b/src/dialogs/passage-edit/passage-edit.tsx index 2e94a1d9e..b42eaac29 100644 --- a/src/dialogs/passage-edit/passage-edit.tsx +++ b/src/dialogs/passage-edit/passage-edit.tsx @@ -10,6 +10,10 @@ import { import {CheckboxButton} from '../../components/control/checkbox-button'; import {MenuButton} from '../../components/control/menu-button'; import {AddTagButton, TagButton} from '../../components/tag'; +import { + formatWithNameAndVersion, + useStoryFormatsContext +} from '../../store/story-formats'; import { addPassageTag, passageWithId, @@ -36,8 +40,14 @@ export const PassageEditDialog: React.FC = props => { const {passageId, storyId, ...other} = props; const [cmEditor, setCmEditor] = React.useState(); const {dispatch, stories} = useUndoableStoriesContext(); + const {formats} = useStoryFormatsContext(); const passage = passageWithId(stories, storyId, passageId); const story = storyWithId(stories, storyId); + const storyFormat = formatWithNameAndVersion( + formats, + story.storyFormat, + story.storyFormatVersion + ); const {t} = useTranslation(); // TODO: make tag changes undoable @@ -150,6 +160,7 @@ export const PassageEditDialog: React.FC = props => { onChange={handlePassageTextChange} onEditorChange={setCmEditor} passage={passage} + storyFormat={storyFormat} /> ); diff --git a/src/dialogs/passage-edit/passage-text.tsx b/src/dialogs/passage-edit/passage-text.tsx index 0e1b6f3ff..46a8ed43b 100644 --- a/src/dialogs/passage-edit/passage-text.tsx +++ b/src/dialogs/passage-edit/passage-text.tsx @@ -3,16 +3,35 @@ import {DialogEditor} from '../../components/container/dialog-card'; import {CodeArea} from '../../components/control/code-area'; import {usePrefsContext} from '../../store/prefs'; import {Passage} from '../../store/stories'; +import {StoryFormat, useFormatCodeMirrorMode} from '../../store/story-formats'; export interface PassageTextProps { onChange: (value: string) => void; onEditorChange: (value: CodeMirror.Editor) => void; passage: Passage; + storyFormat: StoryFormat; } export const PassageText: React.FC = props => { - const {onChange, onEditorChange, passage} = props; + const {onChange, onEditorChange, passage, storyFormat} = props; + const [editor, setEditor] = React.useState(); const {prefs} = usePrefsContext(); + const mode = + useFormatCodeMirrorMode(storyFormat.name, storyFormat.version) ?? 'text'; + + function handleMount(editor: CodeMirror.Editor) { + setEditor(editor); + onEditorChange(editor); + + // The potential combination of loading a mode and the dialog entrance + // animation seems to mess up CodeMirror's cursor rendering. The delay below + // is intended to run after the animation completes. + + window.setTimeout(() => { + editor.focus(); + editor.refresh(); + }, 400); + } function handleChange( editor: CodeMirror.Editor, @@ -26,12 +45,12 @@ export const PassageText: React.FC = props => { return ( diff --git a/src/store/persistence/electron-ipc/stories/__tests__/save-story.test.ts b/src/store/persistence/electron-ipc/stories/__tests__/save-story.test.ts index 3bc9dcea9..67470b87c 100644 --- a/src/store/persistence/electron-ipc/stories/__tests__/save-story.test.ts +++ b/src/store/persistence/electron-ipc/stories/__tests__/save-story.test.ts @@ -11,7 +11,7 @@ import { publishStory, publishStoryWithFormat } from '../../../../../util/publish'; -import * as fetchStoryFormatProperties from '../../../../../util/fetch-story-format-properties'; +import * as fetchStoryFormatProperties from '../../../../../util/story-format/fetch-properties'; import {saveStory} from '../save-story'; describe('saveStory()', () => { diff --git a/src/store/persistence/electron-ipc/stories/save-story.ts b/src/store/persistence/electron-ipc/stories/save-story.ts index f83205f47..6e024bfa7 100644 --- a/src/store/persistence/electron-ipc/stories/save-story.ts +++ b/src/store/persistence/electron-ipc/stories/save-story.ts @@ -6,7 +6,7 @@ import { StoryFormatsState } from '../../../story-formats'; import {getAppInfo} from '../../../../util/app-info'; -import {fetchStoryFormatProperties} from '../../../../util/fetch-story-format-properties'; +import {fetchStoryFormatProperties} from '../../../../util/story-format/fetch-properties'; /** * Sends an IPC message to save a story to disk, ideally in published form. diff --git a/src/store/story-formats/__tests__/action-creators.test.ts b/src/store/story-formats/__tests__/action-creators.test.ts index 3b83add77..e2026b553 100644 --- a/src/store/story-formats/__tests__/action-creators.test.ts +++ b/src/store/story-formats/__tests__/action-creators.test.ts @@ -5,7 +5,7 @@ import { loadFormatProperties } from '../action-creators'; import {StoryFormat, StoryFormatProperties} from '../story-formats.types'; -import {fetchStoryFormatProperties} from '../../../util/fetch-story-format-properties'; +import {fetchStoryFormatProperties} from '../../../util/story-format/fetch-properties'; import { fakeFailedStoryFormat, fakeLoadedStoryFormat, @@ -14,7 +14,7 @@ import { fakeUnloadedStoryFormat } from '../../../test-util/fakes'; -jest.mock('../../../util/fetch-story-format-properties'); +jest.mock('../../../util/story-format/fetch-properties'); describe('createFromProperties', () => { it('returns a create action with the URL and properties specified', () => { @@ -156,6 +156,65 @@ describe('loadFormatProperties', () => { it('returns the format properties', async () => expect(await loadFormatProperties(format)(dispatch)).toBe(properties)); + + describe.only('if the format properties contain a hydrate property', () => { + it('merges in properties set on this by the hydrate property', async () => { + properties.hydrate = 'this.hydrated = true'; + await loadFormatProperties(format)(dispatch); + expect(dispatch.mock.calls).toEqual([ + [{type: 'update', id: format.id, props: {loadState: 'loading'}}], + [ + { + type: 'update', + id: format.id, + props: { + loadState: 'loaded', + properties: {...properties, hydrated: true} + } + } + ] + ]); + }); + + it('does not allow overwriting static properties in the format', async () => { + const origName = properties.name; + + properties.hydrate = 'this.name = "failed"'; + await loadFormatProperties(format)(dispatch); + expect(dispatch.mock.calls).toEqual([ + [{type: 'update', id: format.id, props: {loadState: 'loading'}}], + [ + { + type: 'update', + id: format.id, + props: { + loadState: 'loaded', + properties: {...properties, name: origName} + } + } + ] + ]); + }); + + it('does not throw an error and does not alter properties if the hydrate property throws', async () => { + jest.spyOn(console, 'error').mockReturnValue(); + properties.hydrate = 'this.hydrated = true; throw new Error();'; + await loadFormatProperties(format)(dispatch); + expect(dispatch.mock.calls).toEqual([ + [{type: 'update', id: format.id, props: {loadState: 'loading'}}], + [ + { + type: 'update', + id: format.id, + props: { + loadState: 'loaded', + properties + } + } + ] + ]); + }); + }); }); describe('when fetching properties fails', () => { diff --git a/src/store/story-formats/__tests__/use-format-codemirror-mode.test.ts b/src/store/story-formats/__tests__/use-format-codemirror-mode.test.ts new file mode 100644 index 000000000..aa77bf2f8 --- /dev/null +++ b/src/store/story-formats/__tests__/use-format-codemirror-mode.test.ts @@ -0,0 +1,132 @@ +import {version as twineVersion} from '../../../../package.json'; +import CodeMirror from 'codemirror'; +import {renderHook} from '@testing-library/react-hooks'; +import {useFormatCodeMirrorMode} from '..'; +import { + fakeFailedStoryFormat, + fakeLoadedStoryFormat, + fakePendingStoryFormat, + fakeUnloadedStoryFormat +} from '../../../test-util/fakes'; +import {useStoryFormatsContext} from '../story-formats-context'; +import {loadFormatProperties} from '../action-creators'; +import {StoryFormat} from '../story-formats.types'; + +jest.mock('codemirror'); +jest.mock('../story-formats-context'); +jest.mock('../action-creators'); + +describe('useFormatCodeMirrorMode()', () => { + const codeMirrorDefineModeMock = CodeMirror.defineMode as jest.Mock; + const loadFormatPropertiesMock = loadFormatProperties as jest.Mock; + let format: StoryFormat; + let dispatchMock: jest.Mock; + + describe('when the format is unloaded', () => { + beforeEach(() => { + dispatchMock = jest.fn(); + format = fakeUnloadedStoryFormat(); + loadFormatPropertiesMock.mockReturnValue({mockLoadFormatAction: true}); + (useStoryFormatsContext as jest.Mock).mockReturnValue({ + dispatch: dispatchMock, + formats: [format] + }); + }); + + it('returns undefined', () => + expect( + renderHook(() => useFormatCodeMirrorMode(format.name, format.version)) + .result.current + ).toBeUndefined()); + + it('loads the format', () => { + renderHook(() => useFormatCodeMirrorMode(format.name, format.version)); + expect(dispatchMock.mock.calls).toEqual([[{mockLoadFormatAction: true}]]); + expect(loadFormatPropertiesMock.mock.calls).toEqual([[format]]); + }); + }); + + describe('when the format is loaded', () => { + beforeEach(() => { + dispatchMock = jest.fn(); + format = fakeLoadedStoryFormat(); + (useStoryFormatsContext as jest.Mock).mockReturnValue({ + dispatch: dispatchMock, + formats: [format] + }); + }); + + describe('when the format has a CodeMirror mode', () => { + const mockMode = {mockMode: true}; + + beforeEach(() => { + format.name = 'Predictable Name'; + format.version = '1.2.3'; + (format as any).properties.editorExtensions = { + twine: { + [twineVersion]: { + codeMirror: { + mode: mockMode + } + } + } + }; + }); + + it('defines the mode in the global CodeMirror instance', () => { + renderHook(() => useFormatCodeMirrorMode(format.name, format.version)); + expect(codeMirrorDefineModeMock.mock.calls).toEqual([ + ['predictable-name-1.2.3', mockMode] + ]); + }); + + it('returns the name of the mode', () => + expect( + renderHook(() => useFormatCodeMirrorMode(format.name, format.version)) + .result.current + ).toBe('predictable-name-1.2.3')); + }); + + describe("when the format doesn't have a CodeMirror mode", () => { + it('returns undefined', () => { + jest.spyOn(console, 'info').mockReturnValue(); + expect( + renderHook(() => useFormatCodeMirrorMode(format.name, format.version)) + .result.current + ).toBeUndefined(); + }); + }); + }); + + it('returns undefined if the format is loading', () => { + format = fakePendingStoryFormat(); + (useStoryFormatsContext as jest.Mock).mockReturnValue({ + dispatch: dispatchMock, + formats: [format] + }); + + expect( + renderHook(() => useFormatCodeMirrorMode(format.name, format.version)) + .result.current + ).toBeUndefined(); + }); + + it('returns undefined if the format failed to load', () => { + format = fakeFailedStoryFormat(); + (useStoryFormatsContext as jest.Mock).mockReturnValue({ + dispatch: dispatchMock, + formats: [format] + }); + + expect( + renderHook(() => useFormatCodeMirrorMode(format.name, format.version)) + .result.current + ).toBeUndefined(); + }); + + it('throws an error if a nonexistent format is requested', () => + expect( + renderHook(() => useFormatCodeMirrorMode('nonexistent', '1.0.0')).result + .error + ).not.toBeUndefined()); +}); diff --git a/src/store/story-formats/action-creators.ts b/src/store/story-formats/action-creators.ts index 58d79e2da..3ddc8fe12 100644 --- a/src/store/story-formats/action-creators.ts +++ b/src/store/story-formats/action-creators.ts @@ -1,5 +1,5 @@ import {Thunk} from 'react-hook-thunk-reducer'; -import {fetchStoryFormatProperties} from '../../util/fetch-story-format-properties'; +import {fetchStoryFormatProperties} from '../../util/story-format/fetch-properties'; import { StoryFormat, StoryFormatProperties, @@ -41,7 +41,30 @@ async function loadFormatThunk( }); try { - const properties = await fetchStoryFormatProperties(format.url); + let properties = await fetchStoryFormatProperties(format.url); + + // If the format contains a `hydrate` property, try running it and merge in + // properties that function creates by modifiying what's bound to its + // `this`. This allows creation of properties that can't be serialized to + // JSON on the format. The hydrate function cannot override properties + // already present in the format properties, e.g. to change its source. + + if (properties.hydrate) { + try { + const hydrateResult: Partial = {}; + + // eslint-disable-next-line no-new-func + const hydrateFunc = new Function(properties.hydrate); + + hydrateFunc.call(hydrateResult); + properties = {...hydrateResult, ...properties}; + } catch (e) { + console.error( + `Format ${format.id} has a hydrate property but it threw an error when called`, + e + ); + } + } dispatch({ type: 'update', diff --git a/src/store/story-formats/index.ts b/src/store/story-formats/index.ts index bafdf0358..d2bacf385 100644 --- a/src/store/story-formats/index.ts +++ b/src/store/story-formats/index.ts @@ -3,3 +3,4 @@ export * from './defaults'; export * from './getters'; export * from './story-formats-context'; export * from './story-formats.types'; +export * from './use-format-codemirror-mode'; diff --git a/src/store/story-formats/story-formats.types.ts b/src/store/story-formats/story-formats.types.ts index 35a07b2a1..4604cdac4 100644 --- a/src/store/story-formats/story-formats.types.ts +++ b/src/store/story-formats/story-formats.types.ts @@ -1,3 +1,4 @@ +import {ModeFactory} from 'codemirror'; import {Thunk} from 'react-hook-thunk-reducer'; interface BaseStoryFormat { @@ -31,6 +32,16 @@ export type StoryFormat = export interface StoryFormatProperties { author?: string; description?: string; + editorExtensions?: { + twine?: { + [semverSpec: string]: { + codeMirror?: { + mode?: ModeFactory; + }; + }; + }; + }; + hydrate?: string; image?: string; license?: string; name: string; diff --git a/src/store/story-formats/use-format-codemirror-mode.ts b/src/store/story-formats/use-format-codemirror-mode.ts new file mode 100644 index 000000000..fc5df4cbb --- /dev/null +++ b/src/store/story-formats/use-format-codemirror-mode.ts @@ -0,0 +1,50 @@ +import {version as twineVersion} from '../../../package.json'; +import CodeMirror from 'codemirror'; +import * as React from 'react'; +import { + formatWithNameAndVersion, + loadFormatProperties, + useStoryFormatsContext +} from '.'; +import {formatEditorExtensions} from '../../util/story-format/editor-extensions'; +import {StoryFormat} from './story-formats.types'; + +/** + * Returns a valid name of a CodeMirror mode for a story format. + */ +function formatModeName(format: StoryFormat) { + return format.name.toLowerCase().replace(/\s/g, '-') + '-' + format.version; +} + +/** + * Sets up a CodeMirror mode for a format, if the format has defined one via + * properties.codeMirror.mode. Once one is fully set up, this returns the name + * of that mode. If the mode is being set up or the format hasn't defined one, + * this returns undefined. + */ +export function useFormatCodeMirrorMode( + formatName: string, + formatVersion: string +) { + const {dispatch, formats} = useStoryFormatsContext(); + const format = formatWithNameAndVersion(formats, formatName, formatVersion); + const [modeName, setModeName] = React.useState(); + + React.useEffect(() => { + if (format.loadState === 'unloaded') { + dispatch(loadFormatProperties(format)); + } else if (format.loadState === 'loaded') { + const editorExtensions = formatEditorExtensions(format, twineVersion); + + if (editorExtensions?.codeMirror?.mode) { + CodeMirror.defineMode( + formatModeName(format), + editorExtensions.codeMirror.mode + ); + setModeName(formatModeName(format)); + } + } + }, [dispatch, format]); + + return modeName; +} diff --git a/src/util/story-format/__tests__/editor-extensions.test.ts b/src/util/story-format/__tests__/editor-extensions.test.ts new file mode 100644 index 000000000..00e75ebbb --- /dev/null +++ b/src/util/story-format/__tests__/editor-extensions.test.ts @@ -0,0 +1,87 @@ +import {formatEditorExtensions} from '../editor-extensions'; +import { + fakeFailedStoryFormat, + fakeLoadedStoryFormat, + fakePendingStoryFormat, + fakeUnloadedStoryFormat +} from '../../../test-util/fakes'; + +describe('formatEditorExtensions()', () => { + it('returns undefined if the format is not loaded', () => { + expect( + formatEditorExtensions(fakeFailedStoryFormat(), '2.4.0') + ).toBeUndefined(); + expect( + formatEditorExtensions(fakePendingStoryFormat(), '2.4.0') + ).toBeUndefined(); + expect( + formatEditorExtensions(fakeUnloadedStoryFormat(), '2.4.0') + ).toBeUndefined(); + }); + + it('returns undefined if the format has no editorExtensions property', () => { + jest.spyOn(console, 'info').mockReturnValue(); + + const format = fakeLoadedStoryFormat(); + + delete (format as any).properties.editorExtensions; + expect(formatEditorExtensions(format, '2.4.0')).toBeUndefined(); + }); + + it('returns undefined if the format has no editorExtensions.twine property', () => { + jest.spyOn(console, 'info').mockReturnValue(); + + const format = fakeLoadedStoryFormat(); + + (format as any).properties.editorExtensions = {}; + expect(formatEditorExtensions(format, '2.4.0')).toBeUndefined(); + (format as any).properties.editorExtensions = { + someOtherTool: {'2.4.0': {}} + }; + expect(formatEditorExtensions(format, '2.4.0')).toBeUndefined(); + }); + + it('returns undefined if no version spec matches the Twine version', () => { + jest.spyOn(console, 'info').mockReturnValue(); + const format = fakeLoadedStoryFormat(); + + (format as any).properties.editorExtensions = {}; + expect(formatEditorExtensions(format, '2.4.0')).toBeUndefined(); + (format as any).properties.editorExtensions = { + twine: {'^3.0.0': {}} + }; + expect(formatEditorExtensions(format, '2.4.0')).toBeUndefined(); + }); + + it('selects the appropriate set of extensions for the Twine version', () => { + const warnSpy = jest.spyOn(console, 'warn').mockReturnValue(); + const format = fakeLoadedStoryFormat(); + + (format as any).properties.editorExtensions = { + twine: { + '^1.0.0': {passed: false}, + '^2.0.0': {passed: true}, + '^3.0.0': {passed: false} + } + }; + expect((formatEditorExtensions(format, '2.4.0') as any).passed).toBe(true); + expect(warnSpy).not.toBeCalled(); + }); + + it('warns if more than one set of extensions matches the Twine version', () => { + const warnSpy = jest.spyOn(console, 'warn').mockReturnValue(); + const format = fakeLoadedStoryFormat(); + + (format as any).properties.editorExtensions = { + twine: { + '^2.0.0': {ambiguous: true}, + '^2.4.0': {ambiguous: true} + } + }; + + expect((formatEditorExtensions(format, '2.4.0') as any).ambiguous).toBe( + true + ); + expect(warnSpy).toBeCalledTimes(1); + }); +}); diff --git a/src/util/__tests__/fetch-story-format-properties.test.ts b/src/util/story-format/__tests__/fetch-properties.test.ts similarity index 91% rename from src/util/__tests__/fetch-story-format-properties.test.ts rename to src/util/story-format/__tests__/fetch-properties.test.ts index 941bf6b36..7fb7c98af 100644 --- a/src/util/__tests__/fetch-story-format-properties.test.ts +++ b/src/util/story-format/__tests__/fetch-properties.test.ts @@ -1,9 +1,9 @@ import jsonp from 'jsonp'; -import {fetchStoryFormatProperties} from '../fetch-story-format-properties'; -import {isElectronRenderer} from '../is-electron'; -import {TwineElectronWindow} from '../../electron/shared'; +import {fetchStoryFormatProperties} from '../fetch-properties'; +import {isElectronRenderer} from '../../is-electron'; +import {TwineElectronWindow} from '../../../electron/shared'; -jest.mock('../is-electron'); +jest.mock('../../is-electron'); jest.mock('jsonp'); describe('fetchStoryFormatProperties', () => { diff --git a/src/util/story-format/editor-extensions.ts b/src/util/story-format/editor-extensions.ts new file mode 100644 index 000000000..74ddac7e7 --- /dev/null +++ b/src/util/story-format/editor-extensions.ts @@ -0,0 +1,41 @@ +import {StoryFormat} from '../../store/story-formats'; +import {satisfies} from 'semver'; + +/** + * Returns editor extensions in a format that fit the Twine version specified. + * If none are available, then this returns undefined. + */ +export function formatEditorExtensions( + format: StoryFormat, + twineVersion: string +) { + if (format.loadState !== 'loaded') { + return; + } + + if (!format.properties.editorExtensions?.twine) { + console.info( + `${format.name} ${format.version} has no Twine editor extensions` + ); + return; + } + + const extensions = Object.keys( + format.properties.editorExtensions.twine + ).filter(semverSpec => satisfies(twineVersion, semverSpec)); + + if (extensions.length === 0) { + console.info( + `${format.name} ${format.version} has editor extensions, but none that ${twineVersion} satisfies` + ); + return; + } + + if (extensions.length > 1) { + console.warn( + `More than one set of editor extensions for ${format.name} ${format.version} is satisfied by ${twineVersion}. Using the first.` + ); + } + + return format.properties.editorExtensions.twine[extensions[0]]; +} diff --git a/src/util/fetch-story-format-properties.ts b/src/util/story-format/fetch-properties.ts similarity index 89% rename from src/util/fetch-story-format-properties.ts rename to src/util/story-format/fetch-properties.ts index 0073216f6..1f564a121 100644 --- a/src/util/fetch-story-format-properties.ts +++ b/src/util/story-format/fetch-properties.ts @@ -15,9 +15,9 @@ // for the same callback. import jsonp from 'jsonp'; -import {TwineElectronWindow} from '../electron/shared'; -import {StoryFormatProperties} from '../store/story-formats'; -import {isElectronRenderer} from './is-electron'; +import {TwineElectronWindow} from '../../electron/shared'; +import {StoryFormatProperties} from '../../store/story-formats'; +import {isElectronRenderer} from '../is-electron'; let requestQueue = Promise.resolve(); From d535ff7bfa94e2870ee71835ce44ae8a236e6672 Mon Sep 17 00:00:00 2001 From: Chris Klimas Date: Sun, 15 Aug 2021 15:02:25 -0400 Subject: [PATCH 08/13] Add story format toolbar capability --- src/dialogs/passage-edit/passage-text.tsx | 26 ++-- .../passage-edit/story-format-toolbar.css | 9 ++ .../passage-edit/story-format-toolbar.tsx | 108 +++++++++++++++ src/store/__tests__/theme-setter.test.tsx | 18 +++ .../__tests__/use-computed-theme.test.tsx | 41 ++++++ src/store/story-formats/index.ts | 1 + .../story-formats/story-formats.types.ts | 34 +++++ .../use-format-codemirror-mode.ts | 17 +-- .../use-format-codemirror-toolbar.ts | 125 ++++++++++++++++++ src/store/theme-setter.tsx | 25 +--- src/store/use-computed-theme.ts | 24 ++++ .../story-format/__tests__/namespace.test.ts | 24 ++++ src/util/story-format/index.ts | 3 + src/util/story-format/namespace.ts | 9 ++ 14 files changed, 421 insertions(+), 43 deletions(-) create mode 100644 src/dialogs/passage-edit/story-format-toolbar.css create mode 100644 src/dialogs/passage-edit/story-format-toolbar.tsx create mode 100644 src/store/__tests__/theme-setter.test.tsx create mode 100644 src/store/__tests__/use-computed-theme.test.tsx create mode 100644 src/store/story-formats/use-format-codemirror-toolbar.ts create mode 100644 src/store/use-computed-theme.ts create mode 100644 src/util/story-format/__tests__/namespace.test.ts create mode 100644 src/util/story-format/index.ts create mode 100644 src/util/story-format/namespace.ts diff --git a/src/dialogs/passage-edit/passage-text.tsx b/src/dialogs/passage-edit/passage-text.tsx index 46a8ed43b..f9df44965 100644 --- a/src/dialogs/passage-edit/passage-text.tsx +++ b/src/dialogs/passage-edit/passage-text.tsx @@ -4,6 +4,7 @@ import {CodeArea} from '../../components/control/code-area'; import {usePrefsContext} from '../../store/prefs'; import {Passage} from '../../store/stories'; import {StoryFormat, useFormatCodeMirrorMode} from '../../store/story-formats'; +import {StoryFormatToolbar} from './story-format-toolbar'; export interface PassageTextProps { onChange: (value: string) => void; @@ -43,16 +44,19 @@ export const PassageText: React.FC = props => { } return ( - - - + <> + + + + + ); }; diff --git a/src/dialogs/passage-edit/story-format-toolbar.css b/src/dialogs/passage-edit/story-format-toolbar.css new file mode 100644 index 000000000..a04c3c0bf --- /dev/null +++ b/src/dialogs/passage-edit/story-format-toolbar.css @@ -0,0 +1,9 @@ +@import '../../styles/metrics.css'; + +/* Keep story format icons reasonably-sized. */ + +.story-format-toolbar .icon-button .icon img { + height: 18px; + margin-right: var(--control-inner-padding); + width: auto; +} diff --git a/src/dialogs/passage-edit/story-format-toolbar.tsx b/src/dialogs/passage-edit/story-format-toolbar.tsx new file mode 100644 index 000000000..58d52b932 --- /dev/null +++ b/src/dialogs/passage-edit/story-format-toolbar.tsx @@ -0,0 +1,108 @@ +import * as React from 'react'; +import CodeMirror from 'codemirror'; +import {usePrefsContext} from '../../store/prefs'; +import { + StoryFormat, + StoryFormatToolbarItem, + useFormatCodeMirrorToolbar +} from '../../store/story-formats'; +import {useComputedTheme} from '../../store/use-computed-theme'; +import {ButtonBar} from '../../components/container/button-bar'; +import {IconButton} from '../../components/control/icon-button'; +import {MenuButton} from '../../components/control/menu-button'; +import './story-format-toolbar.css'; + +export interface StoryFormatToolbarProps { + editor?: CodeMirror.Editor; + storyFormat: StoryFormat; +} + +export const StoryFormatToolbar: React.FC = props => { + const {editor, storyFormat} = props; + const appTheme = useComputedTheme(); + const {prefs} = usePrefsContext(); + const toolbarFactory = useFormatCodeMirrorToolbar( + storyFormat.name, + storyFormat.version + ); + const [toolbarItems, setToolbarItems] = React.useState< + StoryFormatToolbarItem[] + >([]); + + React.useEffect(() => { + if (toolbarFactory && editor) { + try { + setToolbarItems( + toolbarFactory(editor, { + appTheme, + locale: prefs.locale + }) + ); + } catch (error) { + console.error( + `Toolbar function for ${storyFormat.name} ${storyFormat.version} threw an error, skipping update`, + error + ); + } + } else { + setToolbarItems([]); + } + }, [ + appTheme, + editor, + prefs.locale, + storyFormat.name, + storyFormat.version, + toolbarFactory + ]); + + return ( +
+ + {toolbarItems.map((item, index) => { + switch (item.type) { + case 'button': + return ( + } + key={index} + label={item.label} + onClick={() => editor?.execCommand(item.command)} + /> + ); + + case 'menu': { + return ( + } + items={item.items + .filter(subitem => + ['button', 'separator'].includes(subitem.type) + ) + .map(subitem => { + if (subitem.type === 'button') { + return { + type: 'button', + disabled: subitem.disabled, + label: subitem.label, + onClick: () => editor?.execCommand(subitem.command) + }; + } + + return {separator: true}; + })} + key={index} + label={item.label} + /> + ); + } + } + + return null; + })} + +
+ ); +}; diff --git a/src/store/__tests__/theme-setter.test.tsx b/src/store/__tests__/theme-setter.test.tsx new file mode 100644 index 000000000..aafd07c66 --- /dev/null +++ b/src/store/__tests__/theme-setter.test.tsx @@ -0,0 +1,18 @@ +import {ThemeSetter} from '../theme-setter'; +import {render} from '@testing-library/react'; +import {useComputedTheme} from '../use-computed-theme'; + +jest.mock('../use-computed-theme'); + +describe('', () => { + const useComputedThemeMock = useComputedTheme as jest.Mock; + + it("sets the body tag's dataset-app-theme property based on the computed theme", () => { + useComputedThemeMock.mockReturnValue('light'); + render(); + expect(document.body.dataset.appTheme).toBe('light'); + useComputedThemeMock.mockReturnValue('dark'); + render(); + expect(document.body.dataset.appTheme).toBe('dark'); + }); +}); diff --git a/src/store/__tests__/use-computed-theme.test.tsx b/src/store/__tests__/use-computed-theme.test.tsx new file mode 100644 index 000000000..e85bc2bc7 --- /dev/null +++ b/src/store/__tests__/use-computed-theme.test.tsx @@ -0,0 +1,41 @@ +import {renderHook} from '@testing-library/react-hooks'; +import {fakePrefs} from '../../test-util/fakes'; +import {PrefsContext, PrefsState} from '../prefs'; +import {useComputedTheme} from '../use-computed-theme'; + +describe('useComputedTheme()', () => { + function renderWithPrefs(prefs: Partial) { + return renderHook(() => useComputedTheme(), { + wrapper: ({children}) => ( + + {children} + + ) + }); + } + + let darkQueryMock = {addEventListener: jest.fn(), matches: true}; + + // jsdom doesn't implement window.matchMedia, but TS knows about it, so we + // have to do some hacky stuff here. + + beforeEach(() => ((window as any).matchMedia = jest.fn(() => darkQueryMock))); + afterEach(() => delete (window as any).matchMedia); + + it('returns the preference if it is dark or light', () => { + expect(renderWithPrefs({appTheme: 'dark'}).result.current).toBe('dark'); + expect(renderWithPrefs({appTheme: 'light'}).result.current).toBe('light'); + }); + + it('returns the browser theme if the preference is system', () => { + darkQueryMock.matches = true; + expect(renderWithPrefs({appTheme: 'system'}).result.current).toBe('dark'); + darkQueryMock.matches = false; + expect(renderWithPrefs({appTheme: 'system'}).result.current).toBe('light'); + }); +}); diff --git a/src/store/story-formats/index.ts b/src/store/story-formats/index.ts index d2bacf385..53d360fe4 100644 --- a/src/store/story-formats/index.ts +++ b/src/store/story-formats/index.ts @@ -4,3 +4,4 @@ export * from './getters'; export * from './story-formats-context'; export * from './story-formats.types'; export * from './use-format-codemirror-mode'; +export * from './use-format-codemirror-toolbar'; diff --git a/src/store/story-formats/story-formats.types.ts b/src/store/story-formats/story-formats.types.ts index 4604cdac4..f2b3bac69 100644 --- a/src/store/story-formats/story-formats.types.ts +++ b/src/store/story-formats/story-formats.types.ts @@ -22,6 +22,38 @@ export type StoryFormat = properties: StoryFormatProperties; }); +export type StoryFormatToolbarButton = { + type: 'button'; + command: string; + disabled?: boolean; + icon: string; + label: string; +}; + +export type StoryFormatToolbarMenuItem = + | Omit + | {type: 'separator'}; + +export type StoryFormatToolbarItem = + | StoryFormatToolbarButton + | { + type: 'menu'; + disabled: boolean; + icon: string; + items: StoryFormatToolbarMenuItem[]; + label: string; + }; + +export interface StoryFormatToolbarFactoryEnvironment { + appTheme: 'dark' | 'light'; + locale: string; +} + +export type StoryFormatToolbarFactory = ( + editor: CodeMirror.Editor, + environment: StoryFormatToolbarFactoryEnvironment +) => StoryFormatToolbarItem[]; + /** * Properties available once a story format is loaded. Note that some there is * some overlap between this and StoryFormat--this is so that we know certain @@ -36,7 +68,9 @@ export interface StoryFormatProperties { twine?: { [semverSpec: string]: { codeMirror?: { + commands?: Record void>; mode?: ModeFactory; + toolbar?: StoryFormatToolbarFactory; }; }; }; diff --git a/src/store/story-formats/use-format-codemirror-mode.ts b/src/store/story-formats/use-format-codemirror-mode.ts index fc5df4cbb..843386acb 100644 --- a/src/store/story-formats/use-format-codemirror-mode.ts +++ b/src/store/story-formats/use-format-codemirror-mode.ts @@ -6,15 +6,10 @@ import { loadFormatProperties, useStoryFormatsContext } from '.'; -import {formatEditorExtensions} from '../../util/story-format/editor-extensions'; -import {StoryFormat} from './story-formats.types'; - -/** - * Returns a valid name of a CodeMirror mode for a story format. - */ -function formatModeName(format: StoryFormat) { - return format.name.toLowerCase().replace(/\s/g, '-') + '-' + format.version; -} +import { + formatEditorExtensions, + namespaceForFormat +} from '../../util/story-format'; /** * Sets up a CodeMirror mode for a format, if the format has defined one via @@ -38,10 +33,10 @@ export function useFormatCodeMirrorMode( if (editorExtensions?.codeMirror?.mode) { CodeMirror.defineMode( - formatModeName(format), + namespaceForFormat(format), editorExtensions.codeMirror.mode ); - setModeName(formatModeName(format)); + setModeName(namespaceForFormat(format)); } } }, [dispatch, format]); diff --git a/src/store/story-formats/use-format-codemirror-toolbar.ts b/src/store/story-formats/use-format-codemirror-toolbar.ts new file mode 100644 index 000000000..318c1993f --- /dev/null +++ b/src/store/story-formats/use-format-codemirror-toolbar.ts @@ -0,0 +1,125 @@ +import {version as twineVersion} from '../../../package.json'; +import CodeMirror from 'codemirror'; +import * as React from 'react'; +import { + formatWithNameAndVersion, + loadFormatProperties, + useStoryFormatsContext, + StoryFormatToolbarFactory +} from '.'; +import { + formatEditorExtensions, + namespaceForFormat +} from '../../util/story-format'; +import {StoryFormatToolbarFactoryEnvironment} from './story-formats.types'; + +/** + * Manages working with a CodeMirror toolbar for a story format, which consists + * of: + * + * - (optionally, but usually) a set of CodeMirror commands + * - A function that returns an array of objects describing the toolbar, which + * uses those commands for functionality + * + * This loads the story format if it hasn't already been loaded, and installs + * CodeMirror commands it provides. It returns the toolbar factory function if + * everything succeeds. Otherwise, it will return undefined. + */ +export function useFormatCodeMirrorToolbar( + formatName: string, + formatVersion: string +) { + const {dispatch, formats} = useStoryFormatsContext(); + const [loaded, setLoaded] = React.useState>({}); + const [ + toolbarFunc, + setToolbarFunc + ] = React.useState(); + const format = formatWithNameAndVersion(formats, formatName, formatVersion); + + React.useEffect(() => { + if (format.loadState === 'unloaded') { + dispatch(loadFormatProperties(format)); + } else if ( + format.loadState === 'loaded' && + !loaded[namespaceForFormat(format)] + ) { + const namespace = namespaceForFormat(format); + const editorExtensions = formatEditorExtensions(format, twineVersion); + + if (editorExtensions?.codeMirror?.commands) { + Object.keys(editorExtensions.codeMirror.commands).forEach( + commandName => { + const namespacedCommand = namespace + commandName; + + if (namespacedCommand in CodeMirror.commands) { + console.warn( + `CodeMirror already has a "${namespacedCommand}" command defined, skipping` + ); + } else { + // Using any here because the type is defined with factory + // commands only. + + (CodeMirror.commands as any)[ + namespacedCommand + ] = editorExtensions.codeMirror!.commands![commandName]; + } + } + ); + } + + if (editorExtensions?.codeMirror?.toolbar) { + setToolbarFunc( + () => ( + editor: CodeMirror.Editor, + environment: StoryFormatToolbarFactoryEnvironment + ) => { + // If somehow we lost our format's toolbar function, exit early. + + if (!editorExtensions?.codeMirror?.toolbar) { + return []; + } + + return editorExtensions.codeMirror + .toolbar(editor, environment) + .map(item => { + switch (item.type) { + case 'button': + return {...item, command: namespace + item.command}; + + case 'menu': + return { + ...item, + items: item.items.map(subitem => + subitem.type === 'separator' + ? subitem + : { + ...subitem, + command: namespace + subitem.command + } + ) + }; + + default: + // If we don't understand the item object, remove any command on it + // so it's nonfunctional. + + console.warn( + `Don't know how to handle a toolbar item of type "${ + (item as any).type + }", skipping` + ); + + return {...(item as any), command: ''}; + } + }); + } + ); + } + + setLoaded(loaded => ({...loaded, [namespaceForFormat(format)]: true})); + } + }, [dispatch, format, loaded]); + + return toolbarFunc; +} diff --git a/src/store/theme-setter.tsx b/src/store/theme-setter.tsx index c4152d0e1..e34d0e244 100644 --- a/src/store/theme-setter.tsx +++ b/src/store/theme-setter.tsx @@ -1,29 +1,12 @@ import * as React from 'react'; -import {usePrefsContext} from './prefs'; +import {useComputedTheme} from './use-computed-theme'; export function ThemeSetter() { - // See https://developer.mozilla.org/en-US/docs/Web/API/Window/matchMedia - - const {prefs} = usePrefsContext(); - const [mediaQuery] = React.useState( - window.matchMedia('(prefers-color-scheme: dark)') - ); - const [systemTheme, setSystemTheme] = React.useState<'dark' | 'light'>( - mediaQuery.matches ? 'dark' : 'light' - ); - - React.useEffect(() => { - const listener = (event: MediaQueryListEvent) => - setSystemTheme(event.matches ? 'dark' : 'light'); - - mediaQuery.addEventListener('change', listener); - return () => mediaQuery.removeEventListener('change', listener); - }, [mediaQuery]); + const computedTheme = useComputedTheme(); React.useEffect(() => { - document.body.dataset.appTheme = - prefs.appTheme === 'system' ? systemTheme : prefs.appTheme; - }, [prefs.appTheme, systemTheme]); + document.body.dataset.appTheme = computedTheme; + }, [computedTheme]); return null; } diff --git a/src/store/use-computed-theme.ts b/src/store/use-computed-theme.ts new file mode 100644 index 000000000..aa1f87b7b --- /dev/null +++ b/src/store/use-computed-theme.ts @@ -0,0 +1,24 @@ +import * as React from 'react'; +import {usePrefsContext} from './prefs'; + +export function useComputedTheme() { + // See https://developer.mozilla.org/en-US/docs/Web/API/Window/matchMedia + + const {prefs} = usePrefsContext(); + const [mediaQuery] = React.useState( + window.matchMedia('(prefers-color-scheme: dark)') + ); + const [systemTheme, setSystemTheme] = React.useState<'dark' | 'light'>( + mediaQuery.matches ? 'dark' : 'light' + ); + + React.useEffect(() => { + const listener = (event: MediaQueryListEvent) => + setSystemTheme(event.matches ? 'dark' : 'light'); + + mediaQuery.addEventListener('change', listener); + return () => mediaQuery.removeEventListener('change', listener); + }, [mediaQuery]); + + return prefs.appTheme === 'system' ? systemTheme : prefs.appTheme; +} diff --git a/src/util/story-format/__tests__/namespace.test.ts b/src/util/story-format/__tests__/namespace.test.ts new file mode 100644 index 000000000..b9274b515 --- /dev/null +++ b/src/util/story-format/__tests__/namespace.test.ts @@ -0,0 +1,24 @@ +import {namespaceForFormat} from '../namespace'; +import {fakePendingStoryFormat} from '../../../test-util/fakes'; + +describe('namespaceForFormat()', () => { + it('removes whitespace from a format name', () => { + const format = fakePendingStoryFormat({name: '\ta b'}); + + expect(/\s/.test(namespaceForFormat(format))).toBe(false); + }); + + it('returns a value unique between two versions of the same format', () => { + const format = fakePendingStoryFormat({version: '1.2.3'}); + const format2 = {...format, version: '1.2.4'}; + + expect(namespaceForFormat(format)).not.toBe(namespaceForFormat(format2)); + }); + + it('returns a value unique between formats thath have different names', () => { + const format = fakePendingStoryFormat({name: 'foo'}); + const format2 = {...format, name: 'bar'}; + + expect(namespaceForFormat(format)).not.toBe(namespaceForFormat(format2)); + }); +}); diff --git a/src/util/story-format/index.ts b/src/util/story-format/index.ts new file mode 100644 index 000000000..fa53d1a9e --- /dev/null +++ b/src/util/story-format/index.ts @@ -0,0 +1,3 @@ +export * from './editor-extensions'; +export * from './fetch-properties'; +export * from './namespace'; diff --git a/src/util/story-format/namespace.ts b/src/util/story-format/namespace.ts new file mode 100644 index 000000000..d86e67d99 --- /dev/null +++ b/src/util/story-format/namespace.ts @@ -0,0 +1,9 @@ +import {StoryFormat} from '../../store/story-formats'; + +/** + * Returns a namespace prefix for a story format that is compatible with + * CodeMirror names (modes and commands). + */ +export function namespaceForFormat(format: StoryFormat) { + return format.name.toLowerCase().replace(/\s/g, '-') + '-' + format.version; +} From aae881eb9021cd9fca9db766284fd5ca65228848 Mon Sep 17 00:00:00 2001 From: Chris Klimas Date: Tue, 7 Sep 2021 20:53:43 -0400 Subject: [PATCH 09/13] Add story format reference parser capability --- .../broken-connection.css} | 2 +- .../broken-connection.tsx} | 8 +- .../passage/passage-connections/index.ts | 1 + .../passage-connections}/markers.css | 0 .../passage-connections}/markers.tsx | 0 .../passage-connection-group.tsx | 51 ++++++ .../passage-connection.css} | 6 +- .../passage-connection.tsx} | 22 +-- .../passage-connections.tsx | 69 +++++++++ .../passage-connections/self-connection.css} | 6 +- .../passage-connections/self-connection.tsx} | 11 +- .../passage-connections/start-connection.css} | 2 +- .../passage-connections/start-connection.tsx} | 8 +- .../passage/passage-map/passage-map.tsx | 12 +- src/components/story/link-connectors/index.ts | 1 - .../link-connectors/link-connector-group.tsx | 48 ------ .../story/link-connectors/link-connectors.tsx | 36 ----- src/routes/story-edit/story-edit-route.tsx | 2 + src/store/stories/__tests__/getters.test.ts | 145 +++++++++++++++++- src/store/stories/getters.ts | 45 +++--- .../use-format-reference-parser.test.ts | 123 +++++++++++++++ .../story-formats/story-formats.types.ts | 3 + .../use-format-reference-parser.ts | 27 ++++ 23 files changed, 483 insertions(+), 145 deletions(-) rename src/components/{story/link-connectors/broken-connector.css => passage/passage-connections/broken-connection.css} (86%) rename src/components/{story/link-connectors/broken-connector.tsx => passage/passage-connections/broken-connection.tsx} (78%) create mode 100644 src/components/passage/passage-connections/index.ts rename src/components/{story/link-connectors => passage/passage-connections}/markers.css (100%) rename src/components/{story/link-connectors => passage/passage-connections}/markers.tsx (100%) create mode 100644 src/components/passage/passage-connections/passage-connection-group.tsx rename src/components/{story/link-connectors/self-connector.css => passage/passage-connections/passage-connection.css} (61%) rename src/components/{story/link-connectors/passage-connector.tsx => passage/passage-connections/passage-connection.tsx} (83%) create mode 100644 src/components/passage/passage-connections/passage-connections.tsx rename src/components/{story/link-connectors/passage-connector.css => passage/passage-connections/self-connection.css} (63%) rename src/components/{story/link-connectors/self-connector.tsx => passage/passage-connections/self-connection.tsx} (77%) rename src/components/{story/link-connectors/start-connector.css => passage/passage-connections/start-connection.css} (72%) rename src/components/{story/link-connectors/start-connector.tsx => passage/passage-connections/start-connection.tsx} (75%) delete mode 100644 src/components/story/link-connectors/index.ts delete mode 100644 src/components/story/link-connectors/link-connector-group.tsx delete mode 100644 src/components/story/link-connectors/link-connectors.tsx create mode 100644 src/store/story-formats/__tests__/use-format-reference-parser.test.ts create mode 100644 src/store/story-formats/use-format-reference-parser.ts diff --git a/src/components/story/link-connectors/broken-connector.css b/src/components/passage/passage-connections/broken-connection.css similarity index 86% rename from src/components/story/link-connectors/broken-connector.css rename to src/components/passage/passage-connections/broken-connection.css index 0683b1327..46ca4bf81 100644 --- a/src/components/story/link-connectors/broken-connector.css +++ b/src/components/passage/passage-connections/broken-connection.css @@ -1,6 +1,6 @@ @import '../../../styles/colors.css'; -line.broken-connector { +line.broken-connection { /* See note in markers.tsx about how to apply arrowheads. */ fill: none; stroke: var(--dark-red); diff --git a/src/components/story/link-connectors/broken-connector.tsx b/src/components/passage/passage-connections/broken-connection.tsx similarity index 78% rename from src/components/story/link-connectors/broken-connector.tsx rename to src/components/passage/passage-connections/broken-connection.tsx index 0d96b8c39..ae88c344a 100644 --- a/src/components/story/link-connectors/broken-connector.tsx +++ b/src/components/passage/passage-connections/broken-connection.tsx @@ -1,9 +1,9 @@ import * as React from 'react'; import {Passage} from '../../../store/stories'; import {Point} from '../../../util/geometry'; -import './broken-connector.css'; +import './broken-connection.css'; -export interface BrokenConnectorProps { +export interface BrokenConnectionProps { offset: Point; passage: Passage; } @@ -11,7 +11,7 @@ export interface BrokenConnectorProps { // Making this equal in length to . const lineOffset = 25 * Math.sqrt(2); -export const BrokenConnector: React.FC = props => { +export const BrokenConnection: React.FC = props => { const {offset, passage} = props; const start: Point = {left: passage.left, top: passage.top}; @@ -22,7 +22,7 @@ export const BrokenConnector: React.FC = props => { return ( ; + connections: Map>; + offset: Point; + self: Set; + variant?: 'link' | 'reference'; +} + +export const PassageConnectionGroup: React.FC = React.memo( + props => { + const {broken, connections, offset, self, variant = 'link'} = props; + + return ( + <> + {Array.from(connections).map(connection => + Array.from(connection[1]).map(end => ( + + )) + )} + {Array.from(self).map(passage => ( + + ))} + {Array.from(broken).map(passage => ( + + ))} + + ); + } +); diff --git a/src/components/story/link-connectors/self-connector.css b/src/components/passage/passage-connections/passage-connection.css similarity index 61% rename from src/components/story/link-connectors/self-connector.css rename to src/components/passage/passage-connections/passage-connection.css index 3dae7551a..acc489bea 100644 --- a/src/components/story/link-connectors/self-connector.css +++ b/src/components/passage/passage-connections/passage-connection.css @@ -1,8 +1,12 @@ @import '../../../styles/colors.css'; -path.self-connector { +path.passage-connection { /* See note in markers.tsx about how to apply arrowheads. */ fill: none; stroke: var(--gray); stroke-width: 2px; } + +path.passage-connection.variant-reference { + stroke-dasharray: 4px; +} diff --git a/src/components/story/link-connectors/passage-connector.tsx b/src/components/passage/passage-connections/passage-connection.tsx similarity index 83% rename from src/components/story/link-connectors/passage-connector.tsx rename to src/components/passage/passage-connections/passage-connection.tsx index 85e3b955e..060437429 100644 --- a/src/components/story/link-connectors/passage-connector.tsx +++ b/src/components/passage/passage-connections/passage-connection.tsx @@ -8,16 +8,17 @@ import { Point } from '../../../util/geometry'; import {Passage} from '../../../store/stories'; -import './passage-connector.css'; +import './passage-connection.css'; -export interface PassageConnectorProps { +export interface PassageConnectionProps { end: Passage; offset: Point; start: Passage; + variant: 'link' | 'reference'; } -export const PassageConnector: React.FC = props => { - const {end, offset, start} = props; +export const PassageConnection: React.FC = props => { + const {end, offset, start, variant} = props; const path = React.useMemo(() => { // If either passage is selected, offset it. We need to take care not to // overwrite the passage information. @@ -48,11 +49,7 @@ export const PassageConnector: React.FC = props => { // Move both points to where they intersect with the edges of their passages. - startPoint = rectIntersectionWithLine( - offsetStart, - startPoint, - endPoint - ); + startPoint = rectIntersectionWithLine(offsetStart, startPoint, endPoint); if (!startPoint) { return ''; @@ -79,10 +76,7 @@ export const PassageConnector: React.FC = props => { let sweep = startPoint.left < endPoint.left; - if ( - startPoint.left === endPoint.left && - startPoint.top < endPoint.top - ) { + if (startPoint.left === endPoint.left && startPoint.top < endPoint.top) { sweep = true; } @@ -103,7 +97,7 @@ export const PassageConnector: React.FC = props => { return ( ); diff --git a/src/components/passage/passage-connections/passage-connections.tsx b/src/components/passage/passage-connections/passage-connections.tsx new file mode 100644 index 000000000..e86e75a85 --- /dev/null +++ b/src/components/passage/passage-connections/passage-connections.tsx @@ -0,0 +1,69 @@ +import * as React from 'react'; +import {Passage, passageConnections} from '../../../store/stories'; +import {StoryFormat} from '../../../store/story-formats'; +import {Point} from '../../../util/geometry'; +import {PassageConnectionGroup} from './passage-connection-group'; +import {LinkMarkers} from './markers'; +import {StartConnection} from './start-connection'; +import {useFormatReferenceParser} from '../../../store/story-formats/use-format-reference-parser'; + +export interface PassageConnectionsProps { + formatName: string; + formatVersion: string; + offset: Point; + passages: Passage[]; + startPassageId: string; +} + +const emptySet = new Set(); +const noOffset: Point = {left: 0, top: 0}; + +export const PassageConnections: React.FC = props => { + const {formatName, formatVersion, offset, passages, startPassageId} = props; + const referenceParser = useFormatReferenceParser(formatName, formatVersion); + const {draggable: draggableLinks, fixed: fixedLinks} = React.useMemo( + () => passageConnections(passages), + [passages] + ); + const { + draggable: draggableReferences, + fixed: fixedReferences + } = React.useMemo(() => passageConnections(passages, referenceParser), [ + passages, + referenceParser + ]); + + console.log(draggableReferences, fixedReferences); + + const startPassage = React.useMemo( + () => passages.find(passage => passage.id === startPassageId), + [passages, startPassageId] + ); + + // References only show existing connections. + + return ( + + + {startPassage && ( + + )} + + + + + + ); +}; diff --git a/src/components/story/link-connectors/passage-connector.css b/src/components/passage/passage-connections/self-connection.css similarity index 63% rename from src/components/story/link-connectors/passage-connector.css rename to src/components/passage/passage-connections/self-connection.css index 3d75fcd5a..860a260b6 100644 --- a/src/components/story/link-connectors/passage-connector.css +++ b/src/components/passage/passage-connections/self-connection.css @@ -1,8 +1,12 @@ @import '../../../styles/colors.css'; -path.passage-connector { +path.self-connection { /* See note in markers.tsx about how to apply arrowheads. */ fill: none; stroke: var(--gray); stroke-width: 2px; } + +path.self-connection.variant-reference { + stroke-dasharray: 4px; +} diff --git a/src/components/story/link-connectors/self-connector.tsx b/src/components/passage/passage-connections/self-connection.tsx similarity index 77% rename from src/components/story/link-connectors/self-connector.tsx rename to src/components/passage/passage-connections/self-connection.tsx index b2ed92905..82fc1f68b 100644 --- a/src/components/story/link-connectors/self-connector.tsx +++ b/src/components/passage/passage-connections/self-connection.tsx @@ -2,15 +2,16 @@ import * as React from 'react'; import {Passage} from '../../../store/stories'; import {Point} from '../../../util/geometry'; import {arc} from '../../../util/svg'; -import './self-connector.css'; +import './self-connection.css'; -export interface SelfConnectorProps { +export interface SelfConnectionProps { offset: Point; passage: Passage; + variant: 'link' | 'reference'; } -export const SelfConnector: React.FC = props => { - const {offset, passage} = props; +export const SelfConnection: React.FC = props => { + const {offset, passage, variant} = props; const start: Point = {left: passage.left, top: passage.top}; if (passage.selected) { @@ -46,7 +47,7 @@ export const SelfConnector: React.FC = props => { return ( diff --git a/src/components/story/link-connectors/start-connector.css b/src/components/passage/passage-connections/start-connection.css similarity index 72% rename from src/components/story/link-connectors/start-connector.css rename to src/components/passage/passage-connections/start-connection.css index d3ef03ecd..2eaa51d4d 100644 --- a/src/components/story/link-connectors/start-connector.css +++ b/src/components/passage/passage-connections/start-connection.css @@ -1,4 +1,4 @@ -line.start-connector { +line.start-connection { fill: none; stroke: var(--dark-green); stroke-width: 2px; diff --git a/src/components/story/link-connectors/start-connector.tsx b/src/components/passage/passage-connections/start-connection.tsx similarity index 75% rename from src/components/story/link-connectors/start-connector.tsx rename to src/components/passage/passage-connections/start-connection.tsx index f44a7ae9f..3955491e5 100644 --- a/src/components/story/link-connectors/start-connector.tsx +++ b/src/components/passage/passage-connections/start-connection.tsx @@ -1,14 +1,14 @@ import * as React from 'react'; import {Passage} from '../../../store/stories'; import {Point} from '../../../util/geometry'; -import './start-connector.css'; +import './start-connection.css'; -export interface StartConnectorProps { +export interface StartConnectionProps { offset: Point; passage: Passage; } -export const StartConnector: React.FC = React.memo( +export const StartConnection: React.FC = React.memo( props => { const {offset, passage} = props; const start: Point = {left: passage.left, top: passage.top}; @@ -20,7 +20,7 @@ export const StartConnector: React.FC = React.memo( return ( void; onDrag: (change: Point) => void; onEdit: (passage: Passage) => void; @@ -67,6 +69,8 @@ const extraSpace = 500; export const PassageMap: React.FC = props => { const { + formatName, + formatVersion, onDeselect, onDrag, onEdit, @@ -141,7 +145,9 @@ export const PassageMap: React.FC = props => { return (
- ; - links: Map>; - offset: Point; - selfLinks: Set; -} - -export const LinkConnectorGroup: React.FC = React.memo( - props => { - const {brokenLinks, links, offset, selfLinks} = props; - - return ( - <> - {Array.from(links).map(link => - Array.from(link[1]).map(end => ( - - )) - )} - {Array.from(selfLinks).map(passage => ( - - ))} - {Array.from(brokenLinks).map(passage => ( - - ))} - - ); - } -); diff --git a/src/components/story/link-connectors/link-connectors.tsx b/src/components/story/link-connectors/link-connectors.tsx deleted file mode 100644 index 9bfb07125..000000000 --- a/src/components/story/link-connectors/link-connectors.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import * as React from 'react'; -import {LinkConnectorGroup} from './link-connector-group'; -import {LinkMarkers} from './markers'; -import {Point} from '../../../util/geometry'; -import {StartConnector} from './start-connector'; -import {passageLinks, Passage} from '../../../store/stories'; - -export interface LinkConnectorsProps { - offset: Point; - passages: Passage[]; - startPassageId: string; -} - -const noOffset: Point = {left: 0, top: 0}; - -export const LinkConnectors: React.FC = props => { - const {offset, passages, startPassageId} = props; - const {draggable, fixed} = React.useMemo(() => passageLinks(passages), [ - passages - ]); - const startPassage = React.useMemo( - () => passages.find(passage => passage.id === startPassageId), - [passages, startPassageId] - ); - - return ( - - - {startPassage && ( - - )} - - - - ); -}; diff --git a/src/routes/story-edit/story-edit-route.tsx b/src/routes/story-edit/story-edit-route.tsx index 5c5763a80..f59adb226 100644 --- a/src/routes/story-edit/story-edit-route.tsx +++ b/src/routes/story-edit/story-edit-route.tsx @@ -210,6 +210,8 @@ export const InnerStoryEditRoute: React.FC = () => { zoom={story.zoom} /> { +describe('markPassageMatches()', () => { it.each([ [ 'AaBbCc', @@ -58,7 +59,137 @@ describe('markPassageMatches', () => { ); }); -describe('passageWithId', () => { +describe('passageConnections', () => { + it('places links between two unselected passages in the fixed property', () => { + const passages = [ + fakePassage({name: 'a', selected: false, text: '[[b]]'}), + fakePassage({name: 'b', selected: false, text: ''}) + ]; + + expect(passageConnections(passages)).toEqual({ + fixed: { + broken: new Set(), + connections: new Map([[passages[0], new Set([passages[1]])]]), + self: new Set() + }, + draggable: { + broken: new Set(), + connections: new Map(), + self: new Set() + } + }); + }); + + it('places links between a selected and an unselected passage in the draggable property', () => { + const passages = [ + fakePassage({name: 'a', selected: true, text: '[[b]]'}), + fakePassage({name: 'b', selected: false, text: ''}) + ]; + + expect(passageConnections(passages)).toEqual({ + fixed: { + broken: new Set(), + connections: new Map(), + self: new Set() + }, + draggable: { + broken: new Set(), + connections: new Map([[passages[0], new Set([passages[1]])]]), + self: new Set() + } + }); + }); + + it('places links between two selected passages in the draggable property', () => { + const passages = [ + fakePassage({name: 'a', selected: true, text: '[[b]]'}), + fakePassage({name: 'b', selected: true, text: ''}) + ]; + + expect(passageConnections(passages)).toEqual({ + fixed: { + broken: new Set(), + connections: new Map(), + self: new Set() + }, + draggable: { + broken: new Set(), + connections: new Map([[passages[0], new Set([passages[1]])]]), + self: new Set() + } + }); + }); + + it('places a self link of a selected passage in the draggable property', () => { + const passage = fakePassage({name: 'a', selected: true, text: '[[a]]'}); + + expect(passageConnections([passage])).toEqual({ + fixed: { + broken: new Set(), + connections: new Map(), + self: new Set() + }, + draggable: { + broken: new Set(), + connections: new Map(), + self: new Set([passage]) + } + }); + }); + + it('places a self link of an unselected passage in the fixed property', () => { + const passage = fakePassage({name: 'a', selected: false, text: '[[a]]'}); + + expect(passageConnections([passage])).toEqual({ + fixed: { + broken: new Set(), + connections: new Map(), + self: new Set([passage]) + }, + draggable: { + broken: new Set(), + connections: new Map(), + self: new Set() + } + }); + }); + + it('places a broken link of a selected passage in the draggable property', () => { + const passage = fakePassage({name: 'a', selected: true, text: '[[b]]'}); + + expect(passageConnections([passage])).toEqual({ + fixed: { + broken: new Set(), + connections: new Map(), + self: new Set() + }, + draggable: { + broken: new Set([passage]), + connections: new Map(), + self: new Set() + } + }); + }); + + it('places a broken link of an unselected passage in the draggable property', () => { + const passage = fakePassage({name: 'a', selected: false, text: '[[b]]'}); + + expect(passageConnections([passage])).toEqual({ + fixed: { + broken: new Set([passage]), + connections: new Map(), + self: new Set() + }, + draggable: { + broken: new Set(), + connections: new Map(), + self: new Set() + } + }); + }); +}); + +describe('passageWithId()', () => { let story: Story; beforeEach(() => (story = fakeStory(3))); @@ -79,7 +210,7 @@ describe('passageWithId', () => { ).toThrow()); }); -describe('passageWithName', () => { +describe('passageWithName()', () => { let story: Story; beforeEach(() => (story = fakeStory(3))); @@ -100,7 +231,7 @@ describe('passageWithName', () => { ).toThrow()); }); -describe('storyPassageTags', () => { +describe('storyPassageTags()', () => { it('returns a sorted array of unique tags across passages', () => { const story = fakeStory(2); @@ -119,7 +250,7 @@ describe('storyPassageTags', () => { }); }); -describe('storyTags', () => { +describe('storyTags()', () => { it('returns a sorted array of unique tags across stories', () => { const stories = [fakeStory(), fakeStory()]; @@ -138,7 +269,7 @@ describe('storyTags', () => { }); }); -describe('storyWithId', () => { +describe('storyWithId()', () => { let story: Story; beforeEach(() => (story = fakeStory())); @@ -152,7 +283,7 @@ describe('storyWithId', () => { expect(() => storyWithId([story], story.id + 'nonexistent')).toThrow()); }); -describe('storyWithName', () => { +describe('storyWithName()', () => { let story: Story; beforeEach(() => (story = fakeStory())); diff --git a/src/store/stories/getters.ts b/src/store/stories/getters.ts index ba433f853..db25ef5fb 100644 --- a/src/store/stories/getters.ts +++ b/src/store/stories/getters.ts @@ -72,43 +72,50 @@ export function passageWithName( ); } -export function passageLinks(passages: Passage[]) { +/** + * Returns connections between passages in a structure optimized for rendering. + * Connections are divided between draggable and fixed, depending on whether + * either of their passages are selected (and could be dragged by the user). + */ +export function passageConnections( + passages: Passage[], + connectionParser?: (text: string) => string[] +) { + const parser = connectionParser ?? ((text: string) => parseLinks(text, true)); const passageMap = new Map(passages.map(p => [p.name, p])); const result = { draggable: { - brokenLinks: new Set(), - links: new Map>(), - selfLinks: new Set() + broken: new Set(), + connections: new Map>(), + self: new Set() }, fixed: { - brokenLinks: new Set(), - links: new Map>(), - selfLinks: new Set() + broken: new Set(), + connections: new Map>(), + self: new Set() } }; passages.forEach(passage => - parseLinks(passage.text).forEach(linkName => { - if (linkName === passage.name) { - (passage.selected ? result.draggable : result.fixed).selfLinks.add( - passage - ); + parser(passage.text).forEach(targetName => { + if (targetName === passage.name) { + (passage.selected ? result.draggable : result.fixed).self.add(passage); } else { - const linkPassage = passageMap.get(linkName); + const targetPassage = passageMap.get(targetName); - if (linkPassage) { + if (targetPassage) { const target = - passage.selected || linkPassage.selected + passage.selected || targetPassage.selected ? result.draggable : result.fixed; - if (target.links.has(passage)) { - target.links.get(passage)!.add(linkPassage); + if (target.connections.has(passage)) { + target.connections.get(passage)!.add(targetPassage); } else { - target.links.set(passage, new Set([linkPassage])); + target.connections.set(passage, new Set([targetPassage])); } } else { - (passage.selected ? result.draggable : result.fixed).brokenLinks.add( + (passage.selected ? result.draggable : result.fixed).broken.add( passage ); } diff --git a/src/store/story-formats/__tests__/use-format-reference-parser.test.ts b/src/store/story-formats/__tests__/use-format-reference-parser.test.ts new file mode 100644 index 000000000..57d64e972 --- /dev/null +++ b/src/store/story-formats/__tests__/use-format-reference-parser.test.ts @@ -0,0 +1,123 @@ +import {version as twineVersion} from '../../../../package.json'; +import {renderHook} from '@testing-library/react-hooks'; +import {useFormatReferenceParser} from '../use-format-reference-parser'; +import { + fakeFailedStoryFormat, + fakeLoadedStoryFormat, + fakePendingStoryFormat, + fakeUnloadedStoryFormat +} from '../../../test-util/fakes'; +import {useStoryFormatsContext} from '../story-formats-context'; +import {loadFormatProperties} from '../action-creators'; +import {StoryFormat} from '../story-formats.types'; + +jest.mock('../story-formats-context'); +jest.mock('../action-creators'); + +describe('useFormatReferenceParser()', () => { + const loadFormatPropertiesMock = loadFormatProperties as jest.Mock; + let format: StoryFormat; + let dispatchMock: jest.Mock; + + describe('when the format is unloaded', () => { + beforeEach(() => { + dispatchMock = jest.fn(); + format = fakeUnloadedStoryFormat(); + loadFormatPropertiesMock.mockReturnValue({mockLoadFormatAction: true}); + (useStoryFormatsContext as jest.Mock).mockReturnValue({ + dispatch: dispatchMock, + formats: [format] + }); + }); + + it('returns a function returning an empty array', () => { + const {result} = renderHook(() => + useFormatReferenceParser(format.name, format.version) + ); + + expect(result.current('')).toEqual([]); + }); + + it('loads the format', () => { + renderHook(() => useFormatReferenceParser(format.name, format.version)); + expect(dispatchMock.mock.calls).toEqual([[{mockLoadFormatAction: true}]]); + expect(loadFormatPropertiesMock.mock.calls).toEqual([[format]]); + }); + }); + + describe('when the format is loaded', () => { + beforeEach(() => { + dispatchMock = jest.fn(); + format = fakeLoadedStoryFormat(); + (useStoryFormatsContext as jest.Mock).mockReturnValue({ + dispatch: dispatchMock, + formats: [format] + }); + }); + + it('returns its reference parser if defined', () => { + const parsePassageText = jest.fn(); + + format.name = 'Predictable Name'; + format.version = '1.2.3'; + (format as any).properties.editorExtensions = { + twine: { + [twineVersion]: { + references: {parsePassageText} + } + } + }; + + const {result} = renderHook(() => + useFormatReferenceParser(format.name, format.version) + ); + + expect(result.current).toBe(parsePassageText); + }); + + it('retuns a function returning an empty array if a reference parser is not defined', () => { + jest.spyOn(console, 'info').mockReturnValue(); + + const {result} = renderHook(() => + useFormatReferenceParser(format.name, format.version) + ); + + expect(result.current('')).toEqual([]); + }); + }); + + it('returns a function returning an empty array while the format is loading', () => { + format = fakePendingStoryFormat(); + (useStoryFormatsContext as jest.Mock).mockReturnValue({ + dispatch: dispatchMock, + formats: [format] + }); + + const {result} = renderHook(() => + useFormatReferenceParser(format.name, format.version) + ); + + expect(result.current('')).toEqual([]); + }); + + it('returns a function returning an empty array if the format failed to load', () => { + format = fakeFailedStoryFormat(); + (useStoryFormatsContext as jest.Mock).mockReturnValue({ + dispatch: dispatchMock, + formats: [format] + }); + + const {result} = renderHook(() => + useFormatReferenceParser(format.name, format.version) + ); + + expect(result.current('')).toEqual([]); + }); + + it('throws an error if a nonexistent format is requested', () => { + expect( + renderHook(() => useFormatReferenceParser('nonexistent', '1.0.0')).result + .error + ).not.toBeUndefined(); + }); +}); diff --git a/src/store/story-formats/story-formats.types.ts b/src/store/story-formats/story-formats.types.ts index f2b3bac69..d2037258f 100644 --- a/src/store/story-formats/story-formats.types.ts +++ b/src/store/story-formats/story-formats.types.ts @@ -72,6 +72,9 @@ export interface StoryFormatProperties { mode?: ModeFactory; toolbar?: StoryFormatToolbarFactory; }; + references?: { + parsePassageText?: (text: string) => string[]; + }; }; }; }; diff --git a/src/store/story-formats/use-format-reference-parser.ts b/src/store/story-formats/use-format-reference-parser.ts new file mode 100644 index 000000000..8561decb2 --- /dev/null +++ b/src/store/story-formats/use-format-reference-parser.ts @@ -0,0 +1,27 @@ +import * as React from 'react'; +import {version as twineVersion} from '../../../package.json'; +import {formatEditorExtensions} from '../../util/story-format'; +import {formatWithNameAndVersion} from './getters'; +import {useStoryFormatsContext} from './story-formats-context'; +import {loadFormatProperties} from './action-creators'; + +export function useFormatReferenceParser( + formatName: string, + formatVersion: string +) { + const {dispatch, formats} = useStoryFormatsContext(); + const format = formatWithNameAndVersion(formats, formatName, formatVersion); + const [editorExtensions, setEditorExtensions] = React.useState< + ReturnType + >(); + + React.useEffect(() => { + if (format.loadState === 'unloaded') { + dispatch(loadFormatProperties(format)); + } else if (format.loadState === 'loaded') { + setEditorExtensions(formatEditorExtensions(format, twineVersion)); + } + }, [dispatch, format]); + + return editorExtensions?.references?.parsePassageText ?? (() => []); +} From 7d67abd45e0b2289243e125cee64276ddbe62046 Mon Sep 17 00:00:00 2001 From: Chris Klimas Date: Wed, 8 Sep 2021 16:19:41 -0400 Subject: [PATCH 10/13] Allow story format extensions to be disabled --- public/locales/en-US.json | 1 + public/story-formats/chapbook-1.2.1/format.js | 2 +- .../passage-connections.tsx | 3 +- .../story-format-card/story-format-card.tsx | 42 ++- src/dialogs/passage-edit/passage-text.tsx | 3 +- .../passage-edit/story-format-toolbar.tsx | 9 +- .../story-format-list-route.tsx | 45 +++- src/routes/story-list/story-list-route.tsx | 2 +- src/store/__tests__/theme-setter.test.tsx | 4 +- .../use-format-codemirror-mode.test.ts | 252 ++++++++++++++++++ .../use-format-codemirror-toolbar.test.ts | 191 +++++++++++++ .../use-format-reference-parser.test.ts | 250 +++++++++++++++++ src/store/prefs/__tests__/getters.test.ts | 45 ++++ .../__tests__/use-computed-theme.test.tsx | 4 +- .../__tests__/use-donation-check.test.tsx | 4 +- src/store/prefs/action-creators.ts | 1 + src/store/prefs/defaults.ts | 1 + src/store/prefs/getters.ts | 14 + src/store/prefs/index.ts | 1 + src/store/prefs/prefs.types.ts | 8 + src/store/{ => prefs}/use-computed-theme.ts | 2 +- src/store/{ => prefs}/use-donation-check.ts | 2 +- .../use-format-codemirror-mode.test.ts | 132 --------- .../use-format-reference-parser.test.ts | 123 --------- src/store/story-formats/index.ts | 2 - .../use-format-codemirror-toolbar.ts | 125 --------- .../use-format-reference-parser.ts | 27 -- src/store/theme-setter.tsx | 2 +- .../use-format-codemirror-mode.ts | 22 +- src/store/use-format-codemirror-toolbar.ts | 147 ++++++++++ src/store/use-format-reference-parser.ts | 46 ++++ src/test-util/fakes.ts | 8 +- src/util/story-format/__mocks__/namespace.ts | 5 + 33 files changed, 1073 insertions(+), 452 deletions(-) create mode 100644 src/store/__tests__/use-format-codemirror-mode.test.ts create mode 100644 src/store/__tests__/use-format-codemirror-toolbar.test.ts create mode 100644 src/store/__tests__/use-format-reference-parser.test.ts create mode 100644 src/store/prefs/__tests__/getters.test.ts rename src/store/{ => prefs}/__tests__/use-computed-theme.test.tsx (92%) rename src/store/{ => prefs}/__tests__/use-donation-check.test.tsx (91%) create mode 100644 src/store/prefs/getters.ts rename src/store/{ => prefs}/use-computed-theme.ts (94%) rename src/store/{ => prefs}/use-donation-check.ts (92%) delete mode 100644 src/store/story-formats/__tests__/use-format-codemirror-mode.test.ts delete mode 100644 src/store/story-formats/__tests__/use-format-reference-parser.test.ts delete mode 100644 src/store/story-formats/use-format-codemirror-toolbar.ts delete mode 100644 src/store/story-formats/use-format-reference-parser.ts rename src/store/{story-formats => }/use-format-codemirror-mode.ts (71%) create mode 100644 src/store/use-format-codemirror-toolbar.ts create mode 100644 src/store/use-format-reference-parser.ts create mode 100644 src/util/story-format/__mocks__/namespace.ts diff --git a/public/locales/en-US.json b/public/locales/en-US.json index a87e38cf7..ed40cd5e1 100644 --- a/public/locales/en-US.json +++ b/public/locales/en-US.json @@ -104,6 +104,7 @@ "loadingFormat": "Loading this story format...", "loadError": "This story format could not be loaded ({{errorMessage}}).", "name": "{{name}} {{version}}", + "useEditorExtensions": "Use Editor Extensions", "useFormat": "Use As Default Story Format", "useProofingFormat": "Use As Proofing Format" }, diff --git a/public/story-formats/chapbook-1.2.1/format.js b/public/story-formats/chapbook-1.2.1/format.js index 022fb917d..6235e9d23 100644 --- a/public/story-formats/chapbook-1.2.1/format.js +++ b/public/story-formats/chapbook-1.2.1/format.js @@ -1 +1 @@ -window.storyFormat({"author":"Chris Klimas","description":"A Twine story format emphasizing ease of authoring, multimedia, and playability on many different types of devices. Visit the guide for more information.","image":"logo.svg","name":"Chapbook","proofing":false,"source":"{{STORY_NAME}}
    \"\"
    {{STORY_DATA}}","version":"1.2.1"}); \ No newline at end of file +window.storyFormat({"author":"Chris Klimas","description":" This variant contains no testing-related code, and as a result, loads faster for players.","hydrate":"!function(e,t){for(var o in t)e[o]=t[o]}(this,function(e){var t={};function o(n){if(t[n])return t[n].exports;var r=t[n]={i:n,l:!1,exports:{}};return e[n].call(r.exports,r,r.exports,o),r.l=!0,r.exports}return o.m=e,o.c=t,o.d=function(e,t,n){o.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:n})},o.r=function(e){\"undefined\"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:\"Module\"}),Object.defineProperty(e,\"__esModule\",{value:!0})},o.t=function(e,t){if(1&t&&(e=o(e)),8&t)return e;if(4&t&&\"object\"==typeof e&&e&&e.__esModule)return e;var n=Object.create(null);if(o.r(n),Object.defineProperty(n,\"default\",{enumerable:!0,value:e}),2&t&&\"string\"!=typeof e)for(var r in e)o.d(n,r,function(t){return e[t]}.bind(null,r));return n},o.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return o.d(t,\"a\",t),t},o.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},o.p=\"\",o(o.s=127)}({127:function(e,t,o){\"use strict\";var n,r=o(128),l=(n=r)&&n.__esModule?n:{default:n};e.exports={editorExtensions:{twine:l.default}}},128:function(e,t,o){\"use strict\";Object.defineProperty(t,\"__esModule\",{value:!0});var n=u(o(129)),r=u(o(130)),l=u(o(131)),i=u(o(132));function u(e){return e&&e.__esModule?e:{default:e}}t.default={\"^2.4.0-alpha1\":{codeMirror:{commands:r.default,mode:n.default,toolbar:l.default},references:{parsePassageText:i.default}}}},129:function(e,t,o){\"use strict\";Object.defineProperty(t,\"__esModule\",{value:!0}),t.default=function(){return{startState:function(){return{}},token:function(e,t){if(void 0===t.inVarsBlock){for(var o=1,n=e.lookAhead(o);n&&!t.inVarsBlock;)\"--\"===n&&(t.inVarsBlock=!0),n=e.lookAhead(++o);void 0===t.inVarsBlock&&(t.inVarsBlock=!1)}return t.inVarsBlock?e.sol()?e.match(/^--$/,!1)?(t.inVarsBlock=!1,e.skipToEnd(),\"punctuation\"):e.skipTo(\":\")?\"def\":(e.skipToEnd(),null):(e.skipToEnd(),null):e.sol()&&e.match(/^\\[.+\\]$/)?(e.skipToEnd(),\"keyword\"):e.match(/^\\[\\[[^\\]]+\\]\\]/)?\"link\":e.match(/^{[^}]+}/)?\"keyword\":(e.eatWhile(/[^[{]/)||e.skipToEnd(),null)}}}},130:function(e,t,o){\"use strict\";Object.defineProperty(t,\"__esModule\",{value:!0}),t.default={chapbookTest:function(e){console.log(\"Chapbook command!\",e)}}},131:function(e,t,o){\"use strict\";Object.defineProperty(t,\"__esModule\",{value:!0}),t.default=function(e,t){return console.log(\"CodeMirror toolbar\"),console.log(\"editor\",e),console.log(\"environment\",t),[{type:\"button\",command:\"chapbookTest\",icon:\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' class='icon icon-tabler icon-tabler-rocket' width='44' height='44' viewBox='0 0 24 24' stroke-width='1.5' stroke='\"+(\"dark\"===t.appTheme?\"hsl(0, 0%, 70%)\":\"hsl(0, 0%, 30%)\")+\"' fill='none' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath stroke='none' d='M0 0h24v24H0z' fill='none'/%3E%3Cpath d='M4 13a8 8 0 0 1 7 7a6 6 0 0 0 3 -5a9 9 0 0 0 6 -8a3 3 0 0 0 -3 -3a9 9 0 0 0 -8 6a6 6 0 0 0 -5 3' /%3E%3Cpath d='M7 14a6 6 0 0 0 -3 6a6 6 0 0 0 6 -3' /%3E%3Ccircle cx='15' cy='9' r='1' /%3E%3C/svg%3E\",label:\"Chapbook Test 1\"}]}},132:function(e,t,o){\"use strict\";Object.defineProperty(t,\"__esModule\",{value:!0}),t.default=function(e){return[\"test reference\"]}}}));","image":"logo.svg","name":"Chapbook","proofing":false,"source":"{{STORY_NAME}}
      \"\"
      {{STORY_DATA}}","version":"1.3.0"}) \ No newline at end of file diff --git a/src/components/passage/passage-connections/passage-connections.tsx b/src/components/passage/passage-connections/passage-connections.tsx index e86e75a85..c1260b9f7 100644 --- a/src/components/passage/passage-connections/passage-connections.tsx +++ b/src/components/passage/passage-connections/passage-connections.tsx @@ -1,11 +1,10 @@ import * as React from 'react'; import {Passage, passageConnections} from '../../../store/stories'; -import {StoryFormat} from '../../../store/story-formats'; import {Point} from '../../../util/geometry'; import {PassageConnectionGroup} from './passage-connection-group'; import {LinkMarkers} from './markers'; import {StartConnection} from './start-connection'; -import {useFormatReferenceParser} from '../../../store/story-formats/use-format-reference-parser'; +import {useFormatReferenceParser} from '../../../store/use-format-reference-parser'; export interface PassageConnectionsProps { formatName: string; diff --git a/src/components/story-format/story-format-card/story-format-card.tsx b/src/components/story-format/story-format-card/story-format-card.tsx index ce8477895..a4db29625 100644 --- a/src/components/story-format/story-format-card/story-format-card.tsx +++ b/src/components/story-format/story-format-card/story-format-card.tsx @@ -11,14 +11,23 @@ import {StoryFormatCardDetails} from './story-format-card-details'; import './story-format-card.css'; export interface StoryFormatCardProps { + useEditorExtensions: boolean; format: StoryFormat; + onChangeUseEditorExtensions: (value: boolean) => void; onDelete: () => void; onSelect: () => void; selected: boolean; } export const StoryFormatCard: React.FC = props => { - const {format, onDelete, onSelect, selected} = props; + const { + useEditorExtensions: editorExtensionsAllowed, + format, + onChangeUseEditorExtensions: onChangeEditorExtensionsAllowed, + onDelete, + onSelect, + selected + } = props; const {t} = useTranslation(); let image = <>; @@ -51,17 +60,26 @@ export const StoryFormatCard: React.FC = props => { {format.loadState === 'loaded' && ( - + <> + + {!format.properties?.proofing && ( + + )} + )} {format.userAdded && ( { formatsDispatch(createFromProperties(formatUrl, properties)); } + function handleChangeUseEditorExtensions( + value: boolean, + format: StoryFormat + ) { + // This logic is a little backwards--the user is setting whether to use the + // extensions but our preferences track disabled ones. + + if (value) { + prefsDispatch( + setPref( + 'disabledStoryFormatEditorExtensions', + prefs.disabledStoryFormatEditorExtensions.filter( + f => f.name !== format.name || f.version !== format.version + ) + ) + ); + } else { + prefsDispatch( + setPref('disabledStoryFormatEditorExtensions', [ + ...prefs.disabledStoryFormatEditorExtensions, + {name: format.name, version: format.version} + ]) + ); + } + } + function handleChangeFilter(value: PrefsState['storyFormatListFilter']) { prefsDispatch({type: 'update', name: 'storyFormatListFilter', value}); } @@ -114,11 +145,21 @@ export const StoryFormatListRoute: React.FC = () => { )}

      - + {visibleFormats.map(format => ( + handleChangeUseEditorExtensions(value, format) + } onDelete={() => handleDeleteFormat(format)} onSelect={() => handleSelect(format)} selected={ diff --git a/src/routes/story-list/story-list-route.tsx b/src/routes/story-list/story-list-route.tsx index a8c9e835a..6c381792f 100644 --- a/src/routes/story-list/story-list-route.tsx +++ b/src/routes/story-list/story-list-route.tsx @@ -10,7 +10,7 @@ import { import {usePrefsContext} from '../../store/prefs'; import {Story, useStoriesContext} from '../../store/stories'; import {UndoableStoriesContextProvider} from '../../store/undoable-stories'; -import {useDonationCheck} from '../../store/use-donation-check'; +import {useDonationCheck} from '../../store/prefs/use-donation-check'; import {usePublishing} from '../../store/use-publishing'; import {storyFileName} from '../../electron/shared'; import {saveHtml} from '../../util/save-html'; diff --git a/src/store/__tests__/theme-setter.test.tsx b/src/store/__tests__/theme-setter.test.tsx index aafd07c66..0cb177856 100644 --- a/src/store/__tests__/theme-setter.test.tsx +++ b/src/store/__tests__/theme-setter.test.tsx @@ -1,8 +1,8 @@ import {ThemeSetter} from '../theme-setter'; import {render} from '@testing-library/react'; -import {useComputedTheme} from '../use-computed-theme'; +import {useComputedTheme} from '../prefs/use-computed-theme'; -jest.mock('../use-computed-theme'); +jest.mock('../prefs/use-computed-theme'); describe('', () => { const useComputedThemeMock = useComputedTheme as jest.Mock; diff --git a/src/store/__tests__/use-format-codemirror-mode.test.ts b/src/store/__tests__/use-format-codemirror-mode.test.ts new file mode 100644 index 000000000..f4caa0cb3 --- /dev/null +++ b/src/store/__tests__/use-format-codemirror-mode.test.ts @@ -0,0 +1,252 @@ +import {renderHook} from '@testing-library/react-hooks'; +import CodeMirror from 'codemirror'; +import {version as twineVersion} from '../../../package.json'; +import { + fakeFailedStoryFormat, + fakeLoadedStoryFormat, + fakePendingStoryFormat, + fakePrefs, + fakeUnloadedStoryFormat +} from '../../test-util/fakes'; +import {usePrefsContext} from '../prefs'; +import { + loadFormatProperties, + StoryFormat, + useStoryFormatsContext +} from '../story-formats'; +import {useFormatCodeMirrorMode} from '../use-format-codemirror-mode'; + +jest.mock('codemirror'); +jest.mock('../prefs/prefs-context'); +jest.mock('../story-formats/story-formats-context'); +jest.mock('../story-formats/action-creators'); + +describe('useFormatCodeMirrorMode()', () => { + const codeMirrorDefineModeMock = CodeMirror.defineMode as jest.Mock; + const loadFormatPropertiesMock = loadFormatProperties as jest.Mock; + let format: StoryFormat; + let dispatchMock: jest.Mock; + + describe('when the format extensions are enabled', () => { + beforeEach(() => + (usePrefsContext as jest.Mock).mockReturnValue({ + dispatch: jest.fn(), + prefs: fakePrefs() + }) + ); + + describe('when the format is unloaded', () => { + beforeEach(() => { + dispatchMock = jest.fn(); + format = fakeUnloadedStoryFormat(); + loadFormatPropertiesMock.mockReturnValue({mockLoadFormatAction: true}); + (useStoryFormatsContext as jest.Mock).mockReturnValue({ + dispatch: dispatchMock, + formats: [format] + }); + }); + + it('returns undefined', () => + expect( + renderHook(() => useFormatCodeMirrorMode(format.name, format.version)) + .result.current + ).toBeUndefined()); + + it('loads the format', () => { + renderHook(() => useFormatCodeMirrorMode(format.name, format.version)); + expect(dispatchMock.mock.calls).toEqual([ + [{mockLoadFormatAction: true}] + ]); + expect(loadFormatPropertiesMock.mock.calls).toEqual([[format]]); + }); + }); + + describe('when the format is loaded', () => { + beforeEach(() => { + dispatchMock = jest.fn(); + format = fakeLoadedStoryFormat(); + (useStoryFormatsContext as jest.Mock).mockReturnValue({ + dispatch: dispatchMock, + formats: [format] + }); + }); + + describe('when the format has a CodeMirror mode', () => { + const mockMode = {mockMode: true}; + + beforeEach(() => { + format.name = 'Predictable Name'; + format.version = '1.2.3'; + (format as any).properties.editorExtensions = { + twine: { + [twineVersion]: { + codeMirror: { + mode: mockMode + } + } + } + }; + }); + + it('defines the mode in the global CodeMirror instance', () => { + renderHook(() => + useFormatCodeMirrorMode(format.name, format.version) + ); + expect(codeMirrorDefineModeMock.mock.calls).toEqual([ + ['predictable-name-1.2.3', mockMode] + ]); + }); + + it('returns the name of the mode', () => + expect( + renderHook(() => + useFormatCodeMirrorMode(format.name, format.version) + ).result.current + ).toBe('predictable-name-1.2.3')); + }); + + describe("when the format doesn't have a CodeMirror mode", () => { + it('returns undefined', () => { + jest.spyOn(console, 'info').mockReturnValue(); + expect( + renderHook(() => + useFormatCodeMirrorMode(format.name, format.version) + ).result.current + ).toBeUndefined(); + }); + }); + }); + + it('returns undefined if the format is loading', () => { + format = fakePendingStoryFormat(); + (useStoryFormatsContext as jest.Mock).mockReturnValue({ + dispatch: dispatchMock, + formats: [format] + }); + + expect( + renderHook(() => useFormatCodeMirrorMode(format.name, format.version)) + .result.current + ).toBeUndefined(); + }); + + it('returns undefined if the format failed to load', () => { + format = fakeFailedStoryFormat(); + (useStoryFormatsContext as jest.Mock).mockReturnValue({ + dispatch: dispatchMock, + formats: [format] + }); + + expect( + renderHook(() => useFormatCodeMirrorMode(format.name, format.version)) + .result.current + ).toBeUndefined(); + }); + + it('throws an error if a nonexistent format is requested', () => + expect( + renderHook(() => useFormatCodeMirrorMode('nonexistent', '1.0.0')).result + .error + ).not.toBeUndefined()); + }); + + describe('when the format extensions are disabled', () => { + describe('when the format is unloaded', () => { + beforeEach(() => { + dispatchMock = jest.fn(); + format = fakeUnloadedStoryFormat(); + (useStoryFormatsContext as jest.Mock).mockReturnValue({ + dispatch: dispatchMock, + formats: [format] + }); + (usePrefsContext as jest.Mock).mockReturnValue({ + dispatch: jest.fn(), + prefs: fakePrefs({ + disabledStoryFormatEditorExtensions: [ + {name: format.name, version: format.version} + ] + }) + }); + }); + + it('returns undefined', () => { + const {result} = renderHook(() => + useFormatCodeMirrorMode(format.name, format.version) + ); + + expect(result.current).toBeUndefined(); + }); + + it('does not load the format', () => { + renderHook(() => useFormatCodeMirrorMode(format.name, format.version)); + expect(dispatchMock).not.toHaveBeenCalled(); + }); + }); + + it('returns undefined when the format is loaded', () => { + dispatchMock = jest.fn(); + format = fakeLoadedStoryFormat(); + (useStoryFormatsContext as jest.Mock).mockReturnValue({ + dispatch: dispatchMock, + formats: [format] + }); + (usePrefsContext as jest.Mock).mockReturnValue({ + dispatch: jest.fn(), + prefs: fakePrefs({ + disabledStoryFormatEditorExtensions: [ + {name: format.name, version: format.version} + ] + }) + }); + + expect( + renderHook(() => useFormatCodeMirrorMode(format.name, format.version)) + .result.current + ).toBeUndefined(); + }); + + it('returns undefined when the format is loading', () => { + dispatchMock = jest.fn(); + format = fakePendingStoryFormat(); + (useStoryFormatsContext as jest.Mock).mockReturnValue({ + dispatch: dispatchMock, + formats: [format] + }); + (usePrefsContext as jest.Mock).mockReturnValue({ + dispatch: jest.fn(), + prefs: fakePrefs({ + disabledStoryFormatEditorExtensions: [ + {name: format.name, version: format.version} + ] + }) + }); + + expect( + renderHook(() => useFormatCodeMirrorMode(format.name, format.version)) + .result.current + ).toBeUndefined(); + }); + + it('returns undefined if the format failed to load', () => { + dispatchMock = jest.fn(); + format = fakeFailedStoryFormat(); + (useStoryFormatsContext as jest.Mock).mockReturnValue({ + dispatch: dispatchMock, + formats: [format] + }); + (usePrefsContext as jest.Mock).mockReturnValue({ + dispatch: jest.fn(), + prefs: fakePrefs({ + disabledStoryFormatEditorExtensions: [ + {name: format.name, version: format.version} + ] + }) + }); + + expect( + renderHook(() => useFormatCodeMirrorMode(format.name, format.version)) + .result.current + ).toBeUndefined(); + }); + }); +}); diff --git a/src/store/__tests__/use-format-codemirror-toolbar.test.ts b/src/store/__tests__/use-format-codemirror-toolbar.test.ts new file mode 100644 index 000000000..d9ee88bb5 --- /dev/null +++ b/src/store/__tests__/use-format-codemirror-toolbar.test.ts @@ -0,0 +1,191 @@ +import {renderHook} from '@testing-library/react-hooks'; +import CodeMirror from 'codemirror'; +import {version as twineVersion} from '../../../package.json'; +import { + fakeFailedStoryFormat, + fakeLoadedStoryFormat, + fakePendingStoryFormat, + fakePrefs, + fakeUnloadedStoryFormat +} from '../../test-util/fakes'; +import {usePrefsContext} from '../prefs'; +import { + loadFormatProperties, + StoryFormat, + useStoryFormatsContext +} from '../story-formats'; +import {useFormatCodeMirrorToolbar} from '../use-format-codemirror-toolbar'; + +jest.mock('codemirror'); +jest.mock('../prefs/prefs-context'); +jest.mock('../story-formats/story-formats-context'); +jest.mock('../story-formats/action-creators'); +jest.mock('../../util/story-format/namespace'); + +describe('useFormatCodeMirrorToolbar()', () => { + const loadFormatPropertiesMock = loadFormatProperties as jest.Mock; + let format: StoryFormat; + let dispatchMock: jest.Mock; + + beforeEach(() => { + (CodeMirror.commands as any) = {}; + dispatchMock = jest.fn(); + }); + + describe('when the format extensions are enabled', () => { + beforeEach(() => + (usePrefsContext as jest.Mock).mockReturnValue({ + dispatch: jest.fn(), + prefs: fakePrefs() + }) + ); + + describe('when the format is unloaded', () => { + beforeEach(() => { + format = fakeUnloadedStoryFormat(); + loadFormatPropertiesMock.mockReturnValue({mockLoadFormatAction: true}); + (useStoryFormatsContext as jest.Mock).mockReturnValue({ + dispatch: dispatchMock, + formats: [format] + }); + }); + + it('returns undefined', () => + expect( + renderHook(() => + useFormatCodeMirrorToolbar(format.name, format.version) + ).result.current + ).toBeUndefined()); + + it('loads the format', () => { + renderHook(() => + useFormatCodeMirrorToolbar(format.name, format.version) + ); + expect(dispatchMock.mock.calls).toEqual([ + [{mockLoadFormatAction: true}] + ]); + expect(loadFormatPropertiesMock.mock.calls).toEqual([[format]]); + }); + }); + + describe('when the format is loaded', () => { + const mockCommands = {mockCommand: jest.fn()}; + const mockToolbarFunction = jest.fn(); + + beforeEach(() => { + format = fakeLoadedStoryFormat(); + format.name = 'Predictable Name'; + format.version = '1.2.3'; + (format as any).properties.editorExtensions = { + twine: { + [twineVersion]: { + codeMirror: { + commands: mockCommands, + toolbar: mockToolbarFunction + } + } + } + }; + (useStoryFormatsContext as jest.Mock).mockReturnValue({ + dispatch: dispatchMock, + formats: [format] + }); + }); + + it('adds CodeMirror commands defined by the format, namespacing them', () => { + renderHook(() => + useFormatCodeMirrorToolbar(format.name, format.version) + ); + expect((CodeMirror.commands as any).mockNamespacemockCommand).toBe( + mockCommands.mockCommand + ); + }); + + it('does not overwrite existing CodeMirror commands', () => { + jest.spyOn(console, 'warn').mockReturnValue(); + + const origCommand = jest.fn(); + + (CodeMirror.commands as any).mockNamespacemockCommand = origCommand; + renderHook(() => + useFormatCodeMirrorToolbar(format.name, format.version) + ); + expect((CodeMirror.commands as any).mockNamespacemockCommand).toBe( + origCommand + ); + }); + + describe('the function it returns', () => { + it('calls the CodeMirror toolbar function if defined', () => { + const {result} = renderHook(() => + useFormatCodeMirrorToolbar(format.name, format.version) + ); + + expect(mockToolbarFunction).not.toHaveBeenCalled(); + (result.current as any)(); + expect(mockToolbarFunction).toHaveBeenCalledTimes(1); + }); + + it("namespaces command names in the toolbar function's return value", () => { + ((format as any).properties.editorExtensions.twine[twineVersion] + .codeMirror.toolbar as jest.Mock).mockReturnValue([ + {type: 'button', command: 'mockCommand'}, + {type: 'menu', items: [{type: 'button', command: 'mockCommand2'}]} + ]); + + const {result} = renderHook(() => + useFormatCodeMirrorToolbar(format.name, format.version) + ); + + const items = (result.current as any)(); + + expect(items).toEqual([ + {type: 'button', command: 'mockNamespacemockCommand'}, + { + type: 'menu', + items: [{type: 'button', command: 'mockNamespacemockCommand2'}] + } + ]); + }); + + it('ignores submenus', () => { + ((format as any).properties.editorExtensions.twine[twineVersion] + .codeMirror.toolbar as jest.Mock).mockReturnValue([ + { + type: 'menu', + items: [ + { + type: 'menu', + items: [{type: 'button', command: 'mockCommand2'}] + } + ] + } + ]); + + const {result} = renderHook(() => + useFormatCodeMirrorToolbar(format.name, format.version) + ); + + const items = (result.current as any)(); + + expect(items).toEqual([{type: 'menu', items: []}]); + }); + + it('ignores separators outside of a menu', () => { + ((format as any).properties.editorExtensions.twine[twineVersion] + .codeMirror.toolbar as jest.Mock).mockReturnValue([ + {type: 'separator'} + ]); + + const {result} = renderHook(() => + useFormatCodeMirrorToolbar(format.name, format.version) + ); + + const items = (result.current as any)(); + + expect(items).toEqual([]); + }); + }); + }); + }); +}); diff --git a/src/store/__tests__/use-format-reference-parser.test.ts b/src/store/__tests__/use-format-reference-parser.test.ts new file mode 100644 index 000000000..c3c984335 --- /dev/null +++ b/src/store/__tests__/use-format-reference-parser.test.ts @@ -0,0 +1,250 @@ +import {version as twineVersion} from '../../../package.json'; +import {renderHook} from '@testing-library/react-hooks'; +import {useFormatReferenceParser} from '../use-format-reference-parser'; +import { + fakeFailedStoryFormat, + fakeLoadedStoryFormat, + fakePendingStoryFormat, + fakePrefs, + fakeUnloadedStoryFormat +} from '../../test-util/fakes'; +import { + loadFormatProperties, + useStoryFormatsContext, + StoryFormat +} from '../story-formats'; +import {usePrefsContext} from '../prefs'; + +jest.mock('../prefs/prefs-context'); +jest.mock('../story-formats/story-formats-context'); +jest.mock('../story-formats/action-creators'); + +describe('useFormatReferenceParser()', () => { + const loadFormatPropertiesMock = loadFormatProperties as jest.Mock; + let format: StoryFormat; + let dispatchMock: jest.Mock; + + describe('when the format extensions are enabled', () => { + beforeEach(() => + (usePrefsContext as jest.Mock).mockReturnValue({ + dispatch: jest.fn(), + prefs: fakePrefs() + }) + ); + + describe('when the format is unloaded', () => { + beforeEach(() => { + dispatchMock = jest.fn(); + format = fakeUnloadedStoryFormat(); + loadFormatPropertiesMock.mockReturnValue({mockLoadFormatAction: true}); + (useStoryFormatsContext as jest.Mock).mockReturnValue({ + dispatch: dispatchMock, + formats: [format] + }); + }); + + it('returns a function returning an empty array', () => { + const {result} = renderHook(() => + useFormatReferenceParser(format.name, format.version) + ); + + expect(result.current('')).toEqual([]); + }); + + it('loads the format', () => { + renderHook(() => useFormatReferenceParser(format.name, format.version)); + expect(dispatchMock.mock.calls).toEqual([ + [{mockLoadFormatAction: true}] + ]); + expect(loadFormatPropertiesMock.mock.calls).toEqual([[format]]); + }); + }); + + describe('when the format is loaded', () => { + beforeEach(() => { + dispatchMock = jest.fn(); + format = fakeLoadedStoryFormat(); + (useStoryFormatsContext as jest.Mock).mockReturnValue({ + dispatch: dispatchMock, + formats: [format] + }); + }); + + it('returns its reference parser if defined', () => { + const parsePassageText = jest.fn(); + + format.name = 'Predictable Name'; + format.version = '1.2.3'; + (format as any).properties.editorExtensions = { + twine: { + [twineVersion]: { + references: {parsePassageText} + } + } + }; + + const {result} = renderHook(() => + useFormatReferenceParser(format.name, format.version) + ); + + expect(result.current).toBe(parsePassageText); + }); + + it('retuns a function returning an empty array if a reference parser is not defined', () => { + jest.spyOn(console, 'info').mockReturnValue(); + + const {result} = renderHook(() => + useFormatReferenceParser(format.name, format.version) + ); + + expect(result.current('')).toEqual([]); + }); + }); + + it('returns a function returning an empty array while the format is loading', () => { + format = fakePendingStoryFormat(); + (useStoryFormatsContext as jest.Mock).mockReturnValue({ + dispatch: dispatchMock, + formats: [format] + }); + + const {result} = renderHook(() => + useFormatReferenceParser(format.name, format.version) + ); + + expect(result.current('')).toEqual([]); + }); + + it('returns a function returning an empty array if the format failed to load', () => { + format = fakeFailedStoryFormat(); + (useStoryFormatsContext as jest.Mock).mockReturnValue({ + dispatch: dispatchMock, + formats: [format] + }); + + const {result} = renderHook(() => + useFormatReferenceParser(format.name, format.version) + ); + + expect(result.current('')).toEqual([]); + }); + + it('throws an error if a nonexistent format is requested', () => { + expect( + renderHook(() => useFormatReferenceParser('nonexistent', '1.0.0')) + .result.error + ).not.toBeUndefined(); + }); + }); + + describe('when the format extensions are disabled', () => { + beforeEach(() => + (usePrefsContext as jest.Mock).mockReturnValue({ + dispatch: jest.fn(), + prefs: fakePrefs({ + disabledStoryFormatEditorExtensions: [ + {name: format.name, version: format.version} + ] + }) + }) + ); + + describe('when the format is unloaded', () => { + beforeEach(() => { + dispatchMock = jest.fn(); + format = fakeUnloadedStoryFormat(); + (useStoryFormatsContext as jest.Mock).mockReturnValue({ + dispatch: dispatchMock, + formats: [format] + }); + (usePrefsContext as jest.Mock).mockReturnValue({ + dispatch: jest.fn(), + prefs: fakePrefs({ + disabledStoryFormatEditorExtensions: [ + {name: format.name, version: format.version} + ] + }) + }); + }); + + it('returns a function returning an empty array', () => { + const {result} = renderHook(() => + useFormatReferenceParser(format.name, format.version) + ); + + expect(result.current('')).toEqual([]); + }); + + it('does not load the format', () => { + renderHook(() => useFormatReferenceParser(format.name, format.version)); + expect(dispatchMock).not.toHaveBeenCalled(); + }); + }); + + it('returns a function returning an empty array if the format is loaded', () => { + format = fakeLoadedStoryFormat(); + (useStoryFormatsContext as jest.Mock).mockReturnValue({ + dispatch: jest.fn(), + formats: [format] + }); + (usePrefsContext as jest.Mock).mockReturnValue({ + dispatch: jest.fn(), + prefs: fakePrefs({ + disabledStoryFormatEditorExtensions: [ + {name: format.name, version: format.version} + ] + }) + }); + + const {result} = renderHook(() => + useFormatReferenceParser(format.name, format.version) + ); + + expect(result.current('')).toEqual([]); + }); + + it('returns a function returning an empty array while the format is loading', () => { + format = fakePendingStoryFormat(); + (useStoryFormatsContext as jest.Mock).mockReturnValue({ + dispatch: dispatchMock, + formats: [format] + }); + (usePrefsContext as jest.Mock).mockReturnValue({ + dispatch: jest.fn(), + prefs: fakePrefs({ + disabledStoryFormatEditorExtensions: [ + {name: format.name, version: format.version} + ] + }) + }); + + const {result} = renderHook(() => + useFormatReferenceParser(format.name, format.version) + ); + + expect(result.current('')).toEqual([]); + }); + + it('returns a function returning an empty array if the format failed to load', () => { + format = fakeFailedStoryFormat(); + (useStoryFormatsContext as jest.Mock).mockReturnValue({ + dispatch: dispatchMock, + formats: [format] + }); + (usePrefsContext as jest.Mock).mockReturnValue({ + dispatch: jest.fn(), + prefs: fakePrefs({ + disabledStoryFormatEditorExtensions: [ + {name: format.name, version: format.version} + ] + }) + }); + + const {result} = renderHook(() => + useFormatReferenceParser(format.name, format.version) + ); + + expect(result.current('')).toEqual([]); + }); + }); +}); diff --git a/src/store/prefs/__tests__/getters.test.ts b/src/store/prefs/__tests__/getters.test.ts new file mode 100644 index 000000000..bce097f65 --- /dev/null +++ b/src/store/prefs/__tests__/getters.test.ts @@ -0,0 +1,45 @@ +import {formatEditorExtensionsDisabled} from '../getters'; +import {fakePrefs} from '../../../test-util/fakes'; +import {PrefsState} from '../prefs.types'; + +describe('formatEditorExtensionsDisabled()', () => { + let prefs: PrefsState; + + beforeEach(() => (prefs = fakePrefs())); + + it('returns true if the user preference contains the format and version', () => + expect( + formatEditorExtensionsDisabled( + prefs, + prefs.disabledStoryFormatEditorExtensions[0].name, + prefs.disabledStoryFormatEditorExtensions[0].version + ) + ).toBe(true)); + + it('returns false if the format name does not match', () => + expect( + formatEditorExtensionsDisabled( + prefs, + prefs.disabledStoryFormatEditorExtensions[0].name + ' bad', + prefs.disabledStoryFormatEditorExtensions[0].version + ) + ).toBe(false)); + + it('returns false if the format version does not match', () => + expect( + formatEditorExtensionsDisabled( + prefs, + prefs.disabledStoryFormatEditorExtensions[0].name, + prefs.disabledStoryFormatEditorExtensions[0].version + ' bad' + ) + ).toBe(false)); + + it('retursn false if neither name or version match', () => + expect( + formatEditorExtensionsDisabled( + prefs, + prefs.disabledStoryFormatEditorExtensions[0].name + 'bad', + prefs.disabledStoryFormatEditorExtensions[0].version + ' bad' + ) + ).toBe(false)); +}); diff --git a/src/store/__tests__/use-computed-theme.test.tsx b/src/store/prefs/__tests__/use-computed-theme.test.tsx similarity index 92% rename from src/store/__tests__/use-computed-theme.test.tsx rename to src/store/prefs/__tests__/use-computed-theme.test.tsx index e85bc2bc7..deb171aa4 100644 --- a/src/store/__tests__/use-computed-theme.test.tsx +++ b/src/store/prefs/__tests__/use-computed-theme.test.tsx @@ -1,6 +1,6 @@ import {renderHook} from '@testing-library/react-hooks'; -import {fakePrefs} from '../../test-util/fakes'; -import {PrefsContext, PrefsState} from '../prefs'; +import {fakePrefs} from '../../../test-util/fakes'; +import {PrefsContext, PrefsState} from '..'; import {useComputedTheme} from '../use-computed-theme'; describe('useComputedTheme()', () => { diff --git a/src/store/__tests__/use-donation-check.test.tsx b/src/store/prefs/__tests__/use-donation-check.test.tsx similarity index 91% rename from src/store/__tests__/use-donation-check.test.tsx rename to src/store/prefs/__tests__/use-donation-check.test.tsx index 815107d29..6bcbd48c9 100644 --- a/src/store/__tests__/use-donation-check.test.tsx +++ b/src/store/prefs/__tests__/use-donation-check.test.tsx @@ -1,7 +1,7 @@ import {renderHook} from '@testing-library/react-hooks'; import {donationDelay, useDonationCheck} from '../use-donation-check'; -import {PrefsContext, PrefsState} from '../prefs'; -import {fakePrefs} from '../../test-util/fakes'; +import {PrefsContext, PrefsState} from '..'; +import {fakePrefs} from '../../../test-util/fakes'; describe('useDonationCheck', () => { function renderWithPrefs(prefs: Partial) { diff --git a/src/store/prefs/action-creators.ts b/src/store/prefs/action-creators.ts index ed2f9815d..b6addc2d9 100644 --- a/src/store/prefs/action-creators.ts +++ b/src/store/prefs/action-creators.ts @@ -8,6 +8,7 @@ export function setPref( | number | string | {name: string; version: string} + | {name: string; version: string}[] | Record ): PrefsAction { return {type: 'update', name, value}; diff --git a/src/store/prefs/defaults.ts b/src/store/prefs/defaults.ts index 519ea14e9..d96e8a730 100644 --- a/src/store/prefs/defaults.ts +++ b/src/store/prefs/defaults.ts @@ -4,6 +4,7 @@ export const defaults = (): PrefsState => ({ appTheme: 'light', codeEditorFontFamily: 'var(--font-monospaced)', codeEditorFontScale: 1, + disabledStoryFormatEditorExtensions: [], donateShown: false, firstRunTime: new Date().getTime(), lastUpdateSeen: '', diff --git a/src/store/prefs/getters.ts b/src/store/prefs/getters.ts new file mode 100644 index 000000000..687a3da7e --- /dev/null +++ b/src/store/prefs/getters.ts @@ -0,0 +1,14 @@ +import {PrefsState} from './prefs.types'; + +/** + * Returns whether the user has disabled editor extensions for a story format. + */ +export function formatEditorExtensionsDisabled( + prefs: PrefsState, + name: string, + version: string +) { + return prefs.disabledStoryFormatEditorExtensions.some( + f => f.name === name && f.version === version + ); +} diff --git a/src/store/prefs/index.ts b/src/store/prefs/index.ts index 471dc37cb..40f11b1a1 100644 --- a/src/store/prefs/index.ts +++ b/src/store/prefs/index.ts @@ -1,4 +1,5 @@ export * from './action-creators'; export * from './defaults'; +export * from './getters'; export * from './prefs-context'; export * from './prefs.types'; diff --git a/src/store/prefs/prefs.types.ts b/src/store/prefs/prefs.types.ts index 7230c0ebc..be475e0e7 100644 --- a/src/store/prefs/prefs.types.ts +++ b/src/store/prefs/prefs.types.ts @@ -11,6 +11,7 @@ export type PrefsAction = | string | string[] | {name: string; version: string} + | {name: string; version: string}[] | Record; } | {type: 'repair'}; @@ -28,6 +29,13 @@ export interface PrefsState { * Font scale (1 being 100%) for the story JS and stylesheet editor. */ codeEditorFontScale: number; + /** + * Story formats whose editor extensions should not be enabled. + */ + disabledStoryFormatEditorExtensions: { + name: string; + version: string; + }[]; /** * Has the donation prompt been shown? */ diff --git a/src/store/use-computed-theme.ts b/src/store/prefs/use-computed-theme.ts similarity index 94% rename from src/store/use-computed-theme.ts rename to src/store/prefs/use-computed-theme.ts index aa1f87b7b..9aa25b2ca 100644 --- a/src/store/use-computed-theme.ts +++ b/src/store/prefs/use-computed-theme.ts @@ -1,5 +1,5 @@ import * as React from 'react'; -import {usePrefsContext} from './prefs'; +import {usePrefsContext} from '.'; export function useComputedTheme() { // See https://developer.mozilla.org/en-US/docs/Web/API/Window/matchMedia diff --git a/src/store/use-donation-check.ts b/src/store/prefs/use-donation-check.ts similarity index 92% rename from src/store/use-donation-check.ts rename to src/store/prefs/use-donation-check.ts index 79ad17cd1..7051ecceb 100644 --- a/src/store/use-donation-check.ts +++ b/src/store/prefs/use-donation-check.ts @@ -1,5 +1,5 @@ import * as React from 'react'; -import {usePrefsContext} from './prefs'; +import {usePrefsContext} from '.'; /** * How long since the user began using Twine to show a donation prompt. It's set diff --git a/src/store/story-formats/__tests__/use-format-codemirror-mode.test.ts b/src/store/story-formats/__tests__/use-format-codemirror-mode.test.ts deleted file mode 100644 index aa77bf2f8..000000000 --- a/src/store/story-formats/__tests__/use-format-codemirror-mode.test.ts +++ /dev/null @@ -1,132 +0,0 @@ -import {version as twineVersion} from '../../../../package.json'; -import CodeMirror from 'codemirror'; -import {renderHook} from '@testing-library/react-hooks'; -import {useFormatCodeMirrorMode} from '..'; -import { - fakeFailedStoryFormat, - fakeLoadedStoryFormat, - fakePendingStoryFormat, - fakeUnloadedStoryFormat -} from '../../../test-util/fakes'; -import {useStoryFormatsContext} from '../story-formats-context'; -import {loadFormatProperties} from '../action-creators'; -import {StoryFormat} from '../story-formats.types'; - -jest.mock('codemirror'); -jest.mock('../story-formats-context'); -jest.mock('../action-creators'); - -describe('useFormatCodeMirrorMode()', () => { - const codeMirrorDefineModeMock = CodeMirror.defineMode as jest.Mock; - const loadFormatPropertiesMock = loadFormatProperties as jest.Mock; - let format: StoryFormat; - let dispatchMock: jest.Mock; - - describe('when the format is unloaded', () => { - beforeEach(() => { - dispatchMock = jest.fn(); - format = fakeUnloadedStoryFormat(); - loadFormatPropertiesMock.mockReturnValue({mockLoadFormatAction: true}); - (useStoryFormatsContext as jest.Mock).mockReturnValue({ - dispatch: dispatchMock, - formats: [format] - }); - }); - - it('returns undefined', () => - expect( - renderHook(() => useFormatCodeMirrorMode(format.name, format.version)) - .result.current - ).toBeUndefined()); - - it('loads the format', () => { - renderHook(() => useFormatCodeMirrorMode(format.name, format.version)); - expect(dispatchMock.mock.calls).toEqual([[{mockLoadFormatAction: true}]]); - expect(loadFormatPropertiesMock.mock.calls).toEqual([[format]]); - }); - }); - - describe('when the format is loaded', () => { - beforeEach(() => { - dispatchMock = jest.fn(); - format = fakeLoadedStoryFormat(); - (useStoryFormatsContext as jest.Mock).mockReturnValue({ - dispatch: dispatchMock, - formats: [format] - }); - }); - - describe('when the format has a CodeMirror mode', () => { - const mockMode = {mockMode: true}; - - beforeEach(() => { - format.name = 'Predictable Name'; - format.version = '1.2.3'; - (format as any).properties.editorExtensions = { - twine: { - [twineVersion]: { - codeMirror: { - mode: mockMode - } - } - } - }; - }); - - it('defines the mode in the global CodeMirror instance', () => { - renderHook(() => useFormatCodeMirrorMode(format.name, format.version)); - expect(codeMirrorDefineModeMock.mock.calls).toEqual([ - ['predictable-name-1.2.3', mockMode] - ]); - }); - - it('returns the name of the mode', () => - expect( - renderHook(() => useFormatCodeMirrorMode(format.name, format.version)) - .result.current - ).toBe('predictable-name-1.2.3')); - }); - - describe("when the format doesn't have a CodeMirror mode", () => { - it('returns undefined', () => { - jest.spyOn(console, 'info').mockReturnValue(); - expect( - renderHook(() => useFormatCodeMirrorMode(format.name, format.version)) - .result.current - ).toBeUndefined(); - }); - }); - }); - - it('returns undefined if the format is loading', () => { - format = fakePendingStoryFormat(); - (useStoryFormatsContext as jest.Mock).mockReturnValue({ - dispatch: dispatchMock, - formats: [format] - }); - - expect( - renderHook(() => useFormatCodeMirrorMode(format.name, format.version)) - .result.current - ).toBeUndefined(); - }); - - it('returns undefined if the format failed to load', () => { - format = fakeFailedStoryFormat(); - (useStoryFormatsContext as jest.Mock).mockReturnValue({ - dispatch: dispatchMock, - formats: [format] - }); - - expect( - renderHook(() => useFormatCodeMirrorMode(format.name, format.version)) - .result.current - ).toBeUndefined(); - }); - - it('throws an error if a nonexistent format is requested', () => - expect( - renderHook(() => useFormatCodeMirrorMode('nonexistent', '1.0.0')).result - .error - ).not.toBeUndefined()); -}); diff --git a/src/store/story-formats/__tests__/use-format-reference-parser.test.ts b/src/store/story-formats/__tests__/use-format-reference-parser.test.ts deleted file mode 100644 index 57d64e972..000000000 --- a/src/store/story-formats/__tests__/use-format-reference-parser.test.ts +++ /dev/null @@ -1,123 +0,0 @@ -import {version as twineVersion} from '../../../../package.json'; -import {renderHook} from '@testing-library/react-hooks'; -import {useFormatReferenceParser} from '../use-format-reference-parser'; -import { - fakeFailedStoryFormat, - fakeLoadedStoryFormat, - fakePendingStoryFormat, - fakeUnloadedStoryFormat -} from '../../../test-util/fakes'; -import {useStoryFormatsContext} from '../story-formats-context'; -import {loadFormatProperties} from '../action-creators'; -import {StoryFormat} from '../story-formats.types'; - -jest.mock('../story-formats-context'); -jest.mock('../action-creators'); - -describe('useFormatReferenceParser()', () => { - const loadFormatPropertiesMock = loadFormatProperties as jest.Mock; - let format: StoryFormat; - let dispatchMock: jest.Mock; - - describe('when the format is unloaded', () => { - beforeEach(() => { - dispatchMock = jest.fn(); - format = fakeUnloadedStoryFormat(); - loadFormatPropertiesMock.mockReturnValue({mockLoadFormatAction: true}); - (useStoryFormatsContext as jest.Mock).mockReturnValue({ - dispatch: dispatchMock, - formats: [format] - }); - }); - - it('returns a function returning an empty array', () => { - const {result} = renderHook(() => - useFormatReferenceParser(format.name, format.version) - ); - - expect(result.current('')).toEqual([]); - }); - - it('loads the format', () => { - renderHook(() => useFormatReferenceParser(format.name, format.version)); - expect(dispatchMock.mock.calls).toEqual([[{mockLoadFormatAction: true}]]); - expect(loadFormatPropertiesMock.mock.calls).toEqual([[format]]); - }); - }); - - describe('when the format is loaded', () => { - beforeEach(() => { - dispatchMock = jest.fn(); - format = fakeLoadedStoryFormat(); - (useStoryFormatsContext as jest.Mock).mockReturnValue({ - dispatch: dispatchMock, - formats: [format] - }); - }); - - it('returns its reference parser if defined', () => { - const parsePassageText = jest.fn(); - - format.name = 'Predictable Name'; - format.version = '1.2.3'; - (format as any).properties.editorExtensions = { - twine: { - [twineVersion]: { - references: {parsePassageText} - } - } - }; - - const {result} = renderHook(() => - useFormatReferenceParser(format.name, format.version) - ); - - expect(result.current).toBe(parsePassageText); - }); - - it('retuns a function returning an empty array if a reference parser is not defined', () => { - jest.spyOn(console, 'info').mockReturnValue(); - - const {result} = renderHook(() => - useFormatReferenceParser(format.name, format.version) - ); - - expect(result.current('')).toEqual([]); - }); - }); - - it('returns a function returning an empty array while the format is loading', () => { - format = fakePendingStoryFormat(); - (useStoryFormatsContext as jest.Mock).mockReturnValue({ - dispatch: dispatchMock, - formats: [format] - }); - - const {result} = renderHook(() => - useFormatReferenceParser(format.name, format.version) - ); - - expect(result.current('')).toEqual([]); - }); - - it('returns a function returning an empty array if the format failed to load', () => { - format = fakeFailedStoryFormat(); - (useStoryFormatsContext as jest.Mock).mockReturnValue({ - dispatch: dispatchMock, - formats: [format] - }); - - const {result} = renderHook(() => - useFormatReferenceParser(format.name, format.version) - ); - - expect(result.current('')).toEqual([]); - }); - - it('throws an error if a nonexistent format is requested', () => { - expect( - renderHook(() => useFormatReferenceParser('nonexistent', '1.0.0')).result - .error - ).not.toBeUndefined(); - }); -}); diff --git a/src/store/story-formats/index.ts b/src/store/story-formats/index.ts index 53d360fe4..bafdf0358 100644 --- a/src/store/story-formats/index.ts +++ b/src/store/story-formats/index.ts @@ -3,5 +3,3 @@ export * from './defaults'; export * from './getters'; export * from './story-formats-context'; export * from './story-formats.types'; -export * from './use-format-codemirror-mode'; -export * from './use-format-codemirror-toolbar'; diff --git a/src/store/story-formats/use-format-codemirror-toolbar.ts b/src/store/story-formats/use-format-codemirror-toolbar.ts deleted file mode 100644 index 318c1993f..000000000 --- a/src/store/story-formats/use-format-codemirror-toolbar.ts +++ /dev/null @@ -1,125 +0,0 @@ -import {version as twineVersion} from '../../../package.json'; -import CodeMirror from 'codemirror'; -import * as React from 'react'; -import { - formatWithNameAndVersion, - loadFormatProperties, - useStoryFormatsContext, - StoryFormatToolbarFactory -} from '.'; -import { - formatEditorExtensions, - namespaceForFormat -} from '../../util/story-format'; -import {StoryFormatToolbarFactoryEnvironment} from './story-formats.types'; - -/** - * Manages working with a CodeMirror toolbar for a story format, which consists - * of: - * - * - (optionally, but usually) a set of CodeMirror commands - * - A function that returns an array of objects describing the toolbar, which - * uses those commands for functionality - * - * This loads the story format if it hasn't already been loaded, and installs - * CodeMirror commands it provides. It returns the toolbar factory function if - * everything succeeds. Otherwise, it will return undefined. - */ -export function useFormatCodeMirrorToolbar( - formatName: string, - formatVersion: string -) { - const {dispatch, formats} = useStoryFormatsContext(); - const [loaded, setLoaded] = React.useState>({}); - const [ - toolbarFunc, - setToolbarFunc - ] = React.useState(); - const format = formatWithNameAndVersion(formats, formatName, formatVersion); - - React.useEffect(() => { - if (format.loadState === 'unloaded') { - dispatch(loadFormatProperties(format)); - } else if ( - format.loadState === 'loaded' && - !loaded[namespaceForFormat(format)] - ) { - const namespace = namespaceForFormat(format); - const editorExtensions = formatEditorExtensions(format, twineVersion); - - if (editorExtensions?.codeMirror?.commands) { - Object.keys(editorExtensions.codeMirror.commands).forEach( - commandName => { - const namespacedCommand = namespace + commandName; - - if (namespacedCommand in CodeMirror.commands) { - console.warn( - `CodeMirror already has a "${namespacedCommand}" command defined, skipping` - ); - } else { - // Using any here because the type is defined with factory - // commands only. - - (CodeMirror.commands as any)[ - namespacedCommand - ] = editorExtensions.codeMirror!.commands![commandName]; - } - } - ); - } - - if (editorExtensions?.codeMirror?.toolbar) { - setToolbarFunc( - () => ( - editor: CodeMirror.Editor, - environment: StoryFormatToolbarFactoryEnvironment - ) => { - // If somehow we lost our format's toolbar function, exit early. - - if (!editorExtensions?.codeMirror?.toolbar) { - return []; - } - - return editorExtensions.codeMirror - .toolbar(editor, environment) - .map(item => { - switch (item.type) { - case 'button': - return {...item, command: namespace + item.command}; - - case 'menu': - return { - ...item, - items: item.items.map(subitem => - subitem.type === 'separator' - ? subitem - : { - ...subitem, - command: namespace + subitem.command - } - ) - }; - - default: - // If we don't understand the item object, remove any command on it - // so it's nonfunctional. - - console.warn( - `Don't know how to handle a toolbar item of type "${ - (item as any).type - }", skipping` - ); - - return {...(item as any), command: ''}; - } - }); - } - ); - } - - setLoaded(loaded => ({...loaded, [namespaceForFormat(format)]: true})); - } - }, [dispatch, format, loaded]); - - return toolbarFunc; -} diff --git a/src/store/story-formats/use-format-reference-parser.ts b/src/store/story-formats/use-format-reference-parser.ts deleted file mode 100644 index 8561decb2..000000000 --- a/src/store/story-formats/use-format-reference-parser.ts +++ /dev/null @@ -1,27 +0,0 @@ -import * as React from 'react'; -import {version as twineVersion} from '../../../package.json'; -import {formatEditorExtensions} from '../../util/story-format'; -import {formatWithNameAndVersion} from './getters'; -import {useStoryFormatsContext} from './story-formats-context'; -import {loadFormatProperties} from './action-creators'; - -export function useFormatReferenceParser( - formatName: string, - formatVersion: string -) { - const {dispatch, formats} = useStoryFormatsContext(); - const format = formatWithNameAndVersion(formats, formatName, formatVersion); - const [editorExtensions, setEditorExtensions] = React.useState< - ReturnType - >(); - - React.useEffect(() => { - if (format.loadState === 'unloaded') { - dispatch(loadFormatProperties(format)); - } else if (format.loadState === 'loaded') { - setEditorExtensions(formatEditorExtensions(format, twineVersion)); - } - }, [dispatch, format]); - - return editorExtensions?.references?.parsePassageText ?? (() => []); -} diff --git a/src/store/theme-setter.tsx b/src/store/theme-setter.tsx index e34d0e244..71bdc9abf 100644 --- a/src/store/theme-setter.tsx +++ b/src/store/theme-setter.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import {useComputedTheme} from './use-computed-theme'; +import {useComputedTheme} from './prefs/use-computed-theme'; export function ThemeSetter() { const computedTheme = useComputedTheme(); diff --git a/src/store/story-formats/use-format-codemirror-mode.ts b/src/store/use-format-codemirror-mode.ts similarity index 71% rename from src/store/story-formats/use-format-codemirror-mode.ts rename to src/store/use-format-codemirror-mode.ts index 843386acb..df41feb3e 100644 --- a/src/store/story-formats/use-format-codemirror-mode.ts +++ b/src/store/use-format-codemirror-mode.ts @@ -1,15 +1,13 @@ -import {version as twineVersion} from '../../../package.json'; +import {version as twineVersion} from '../../package.json'; import CodeMirror from 'codemirror'; import * as React from 'react'; import { formatWithNameAndVersion, loadFormatProperties, useStoryFormatsContext -} from '.'; -import { - formatEditorExtensions, - namespaceForFormat -} from '../../util/story-format'; +} from './story-formats'; +import {formatEditorExtensions, namespaceForFormat} from '../util/story-format'; +import {formatEditorExtensionsDisabled, usePrefsContext} from './prefs'; /** * Sets up a CodeMirror mode for a format, if the format has defined one via @@ -23,9 +21,19 @@ export function useFormatCodeMirrorMode( ) { const {dispatch, formats} = useStoryFormatsContext(); const format = formatWithNameAndVersion(formats, formatName, formatVersion); + const {prefs} = usePrefsContext(); const [modeName, setModeName] = React.useState(); + const extensionsDisabled = formatEditorExtensionsDisabled( + prefs, + formatName, + formatVersion + ); React.useEffect(() => { + if (extensionsDisabled) { + return; + } + if (format.loadState === 'unloaded') { dispatch(loadFormatProperties(format)); } else if (format.loadState === 'loaded') { @@ -39,7 +47,7 @@ export function useFormatCodeMirrorMode( setModeName(namespaceForFormat(format)); } } - }, [dispatch, format]); + }, [dispatch, extensionsDisabled, format]); return modeName; } diff --git a/src/store/use-format-codemirror-toolbar.ts b/src/store/use-format-codemirror-toolbar.ts new file mode 100644 index 000000000..0039cf80d --- /dev/null +++ b/src/store/use-format-codemirror-toolbar.ts @@ -0,0 +1,147 @@ +import CodeMirror from 'codemirror'; +import * as React from 'react'; +import {version as twineVersion} from '../../package.json'; +import {formatEditorExtensions, namespaceForFormat} from '../util/story-format'; +import {formatEditorExtensionsDisabled, usePrefsContext} from './prefs'; +import { + formatWithNameAndVersion, + loadFormatProperties, + StoryFormatToolbarFactory, + StoryFormatToolbarFactoryEnvironment, + StoryFormatToolbarItem, + useStoryFormatsContext +} from './story-formats'; + +/** + * Manages working with a CodeMirror toolbar for a story format, which consists + * of: + * + * - (optionally, but usually) a set of CodeMirror commands + * - A function that returns an array of objects describing the toolbar, which + * uses those commands for functionality + * + * This loads the story format if it hasn't already been loaded, and installs + * CodeMirror commands it provides. It returns the toolbar factory function if + * everything succeeds. Otherwise, it will return undefined. + */ +export function useFormatCodeMirrorToolbar( + formatName: string, + formatVersion: string +) { + const {dispatch, formats} = useStoryFormatsContext(); + const [loaded, setLoaded] = React.useState>({}); + const [ + toolbarFunc, + setToolbarFunc + ] = React.useState(); + const format = formatWithNameAndVersion(formats, formatName, formatVersion); + const {prefs} = usePrefsContext(); + const extensionsDisabled = formatEditorExtensionsDisabled( + prefs, + formatName, + formatVersion + ); + + React.useEffect(() => { + if (extensionsDisabled) { + return; + } + + if (format.loadState === 'unloaded') { + dispatch(loadFormatProperties(format)); + } else if ( + format.loadState === 'loaded' && + !loaded[namespaceForFormat(format)] + ) { + const namespace = namespaceForFormat(format); + const editorExtensions = formatEditorExtensions(format, twineVersion); + + if (editorExtensions?.codeMirror?.commands) { + for (const commandName in editorExtensions.codeMirror.commands) { + const namespacedCommand = namespace + commandName; + + if (namespacedCommand in CodeMirror.commands) { + console.warn( + `CodeMirror already has a "${namespacedCommand}" command defined, skipping` + ); + } else { + // Using any here because the type is defined with factory + // commands only. + + (CodeMirror.commands as any)[ + namespacedCommand + ] = editorExtensions.codeMirror!.commands![commandName]; + } + } + } + + if (editorExtensions?.codeMirror?.toolbar) { + setToolbarFunc( + () => ( + editor: CodeMirror.Editor, + environment: StoryFormatToolbarFactoryEnvironment + ) => { + // If somehow we lost our format's toolbar function, exit early. + + if (!editorExtensions?.codeMirror?.toolbar) { + return []; + } + + const items = editorExtensions.codeMirror.toolbar( + editor, + environment + ); + + // If we didn't get an array from the function, coerce it to an + // empty one. + + if (!Array.isArray(items)) { + return []; + } + + // Namespace command properties and filter out any types that aren't + // buttons or menus. + + return items.reduce((result, item) => { + switch (item.type) { + case 'button': + return [ + ...result, + {...item, command: namespace + item.command} + ]; + + case 'menu': + if (Array.isArray(item.items)) { + return [ + ...result, + { + ...item, + items: item.items + .filter(subitem => + ['button', 'separator'].includes(subitem.type) + ) + .map(subitem => + subitem.type === 'separator' + ? subitem + : { + ...subitem, + command: namespace + subitem.command + } + ) + } + ]; + } + } + + return result; + }, [] as StoryFormatToolbarItem[]); + } + ); + } + + setLoaded(loaded => ({...loaded, [namespaceForFormat(format)]: true})); + } + }, [dispatch, extensionsDisabled, format, loaded]); + + return toolbarFunc; +} diff --git a/src/store/use-format-reference-parser.ts b/src/store/use-format-reference-parser.ts new file mode 100644 index 000000000..88dde8e2d --- /dev/null +++ b/src/store/use-format-reference-parser.ts @@ -0,0 +1,46 @@ +import * as React from 'react'; +import {version as twineVersion} from '../../package.json'; +import {formatEditorExtensions} from '../util/story-format'; +import { + formatWithNameAndVersion, + loadFormatProperties, + useStoryFormatsContext +} from './story-formats'; +import {formatEditorExtensionsDisabled, usePrefsContext} from './prefs'; + +const emptyFunc = () => []; + +export function useFormatReferenceParser( + formatName: string, + formatVersion: string +) { + const {prefs} = usePrefsContext(); + const {dispatch, formats} = useStoryFormatsContext(); + const format = formatWithNameAndVersion(formats, formatName, formatVersion); + const [editorExtensions, setEditorExtensions] = React.useState< + ReturnType + >(); + const extensionsDisabled = formatEditorExtensionsDisabled( + prefs, + formatName, + formatVersion + ); + + React.useEffect(() => { + if (extensionsDisabled) { + return; + } + + if (format.loadState === 'unloaded') { + dispatch(loadFormatProperties(format)); + } else if (format.loadState === 'loaded') { + setEditorExtensions(formatEditorExtensions(format, twineVersion)); + } + }, [dispatch, extensionsDisabled, format]); + + if (extensionsDisabled) { + return emptyFunc; + } + + return editorExtensions?.references?.parsePassageText ?? emptyFunc; +} diff --git a/src/test-util/fakes.ts b/src/test-util/fakes.ts index b2a5762c3..cf726b513 100644 --- a/src/test-util/fakes.ts +++ b/src/test-util/fakes.ts @@ -105,7 +105,7 @@ export function fakeUnloadedStoryFormat( }; } -export function fakePrefs(): PrefsState { +export function fakePrefs(overrides?: Partial): PrefsState { // Ensure tag uniqueness. const tag = lorem.word(); @@ -115,6 +115,9 @@ export function fakePrefs(): PrefsState { appTheme: random.arrayElement(['light', 'dark', 'system']), codeEditorFontFamily: lorem.words(2), codeEditorFontScale: 0.8 + random.number(0.5), + disabledStoryFormatEditorExtensions: [ + {name: lorem.words(2), version: system.semver()} + ], donateShown: random.boolean(), firstRunTime: new Date().getTime(), lastUpdateSeen: '', @@ -134,7 +137,8 @@ export function fakePrefs(): PrefsState { storyListSort: random.arrayElement(['date', 'name']), storyListTagFilter: [], storyTagColors: {[tags[0]]: 'red', [tags[1]]: 'green', [tags[2]]: 'blue'}, - welcomeSeen: random.boolean() + welcomeSeen: random.boolean(), + ...overrides }; } diff --git a/src/util/story-format/__mocks__/namespace.ts b/src/util/story-format/__mocks__/namespace.ts new file mode 100644 index 000000000..8098db09a --- /dev/null +++ b/src/util/story-format/__mocks__/namespace.ts @@ -0,0 +1,5 @@ +import {StoryFormat} from '../../../store/story-formats'; + +export function namespaceForFormat(format: StoryFormat) { + return `mockNamespace`; +} From eca9268d4b8d4aacb563bef04ecf663cafc17123 Mon Sep 17 00:00:00 2001 From: Chris Klimas Date: Tue, 14 Sep 2021 19:08:16 -0400 Subject: [PATCH 11/13] Cache dependencies in CI/CD --- .github/workflows/create-release.yml | 3 ++- .github/workflows/prs.yml | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index b86efe6d3..a4a92f083 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -12,8 +12,9 @@ jobs: runs-on: macos-latest steps: - uses: actions/checkout@v2 - - uses: actions/setup-node@v1 + - uses: actions/setup-node@v2 with: + cache: npm node-version: 14 - name: Install run: npm install diff --git a/.github/workflows/prs.yml b/.github/workflows/prs.yml index d6d413b3f..997f34dbc 100644 --- a/.github/workflows/prs.yml +++ b/.github/workflows/prs.yml @@ -11,8 +11,9 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - - uses: actions/setup-node@v1 + - uses: actions/setup-node@v2 with: + cache: npm node-version: 14 - name: Install run: npm install From 137528ffb4bd4f8aaf6691125033d28d169e37fc Mon Sep 17 00:00:00 2001 From: Chris Klimas Date: Thu, 16 Sep 2021 21:57:19 -0400 Subject: [PATCH 12/13] Document extensions --- EXTENDING.md | 481 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 481 insertions(+) create mode 100644 EXTENDING.md diff --git a/EXTENDING.md b/EXTENDING.md new file mode 100644 index 000000000..fc0caf62e --- /dev/null +++ b/EXTENDING.md @@ -0,0 +1,481 @@ +# Extending Twine + +## Introduction + +As of version 2.4, story formats can extend Twine's user interface in specific +ways: + +- A format may add a CodeMirror syntax highlighting mode to the passage editor. +- A format may add custom CodeMirror commands and a toolbar which triggers them, + which also appears above the passage editor. +- A format may add a _reference parser_, which causes connections to appear + between passages. + +This document explains these capabilities and how a story format author +can use them. + +- Creating a standalone extension for Twine is not possible. Extensions can only + be bundled with a story format. +- Users can choose to disable Twine extensions for a story format. For this + reason (and also because users might use other compilers and editors with a + story format), Twine extensions should never be essential for use of a story + format. +- Although extensions are part of a story format file, they will not affect the + size of stories compiled with that format. + +This document does its best to document the behavior of Twine regarding +extensions as completely as it can. However, it may contain mistakes or be +incomplete in places. If your extension makes use of behavior, intentional or +not, that exists in Twine that isn't documented here, then it may break in +future versions of Twine with no warning. + +Before continuing, please read the [Twine 2 story format specs][format-specs]. +These explain the basics of how Twine 2 story formats work. + +## Hydration + +Story formats are encoded in JSONP format: + +```javascript +window.storyFormat({"name": "My Story Format", "version": "1.0.0", "source": "..."}); +``` + +JSONP is itself a thin wrapper over JSON, which does not permit executable code +to be encoded. Instead, Twine 2.4 and later uses a `hydrate` property that +allows for a format to add JavaScript functions or other data types not allowed +by JSON after being loaded. Below is an example of a simple `hydrate` property: + +```javascript +window.storyFormat({ + "name": "My StoryFormat", + "version": "1.0.0", + "hydrate": "this.hydratedProperty = () => console.log('Hello Twine');" +}); +``` + +When Twine loads this format, it creates a JavaScript function from the source +of the `hydrate` property, then executes it, binding `this` to an empty +JavaScript object. The hydrate property can add whatever properties it would +like, which will be merged with the story format properties specified in JSONP. +In the example above, a function called `hydratedProperty` would be added to the +story format object. + +- The `hydrate` property may not contain any asynchronous code. It may add + properties that are themselves asynchronous functions, but the `hydrate` + property itself must be synchronous. +- Check the `browserslist` property of [package.json](package.json) to see what + version of JavaScript Twine supports. +- Only use `hydrate` to add properties that can't be represented in JSONP. +- The `hydrate` property must not contain any side effects, because it may be + called repeatedly by Twine. It should not change the DOM, affect the global + JavaScript scope, or otherwise do anything but define properties on `this`. +- Any properties added through `hydrate` must not conflict with properties + specified in JSONP. Twine will ignore these and use what is in the JSONP. + +You almost certainly will want to use tools to create the `hydrate` property, +similar to how you would compile the `source` property of your format. [An +example repo](story-format-starter) is available demonstrating how to do this +with Webpack 5. The [`--context` option of +Rollup](https://www.rollupjs.org/guide/en/#context) can also be used to bundle +code. + +For clarity's sake, code examples in this document show story formats after they +have been hydrated. + +## Versioning + +The way extensions work in Twine may change in future versions (and probably +will). So that story format authors don't need to publish multiple versions as +Twine changes, Twine's editor extensions are stored under a version specifier: + +```javascript +window.storyFormat({ + "name": "My Story Format", + "version": "1.0.0", + "editorExtensions": { + "twine": { + "^2.4.0-alpha1": { + "anExtensionProperty": "red" + }, + "^3.0.0": { + "anExtensionProperty": "blue" + } + } + } +}); +``` + +In this story format, Twine 2.4.0-alpha1 and later would see +`anExtensionProperty` of `"red"`, but Twine 3.0 (as an example--at time of +writing, this version doesn't exist) would see it as `"blue"`. + +- Twine follows [semantic versioning]. +- Twine uses the `satisfies()` function of the [semver NPM package] to decide if + a specifier matches the current Twine version. You may use any specifier that + this function understands. +- You should never have overlapping version specifiers in your extensions. If + multiple object keys satisfy the version of Twine, then Twine will issue a + warning and use the first it finds that matched. From a practical standpoint, + this means that its behavior is dependent on the browser or platform it's + running on, and cannot be predicted. +- If no object keys satisfy the version of Twine, then no extensions will be + used. +- Only the object keys for a specific story format version will be used. If only + version 2.0.0 of a story format contains extensions but the user is using + version 1.0.0, no extensions will be used. + +## CodeMirror Syntax Highlighting + +Twine uses [CodeMirror] in its passage editor, which allows modes to be defined +which apply formatting to source code. A story format can define its own mode +to, for example, highlight special instructions that the story format accepts. + +A mode is defined using a hydrated function: + +```javascript +window.storyFormat({ + "name": "My Story Format", + "version": "1.0.0", + "editorExtensions": { + "twine": { + "^2.4.0-alpha1": { + "codeMirror": { + mode() { + return { + startState() { + return {}; + }, + token(stream, state) { + stream.skipToEnd(); + return 'keyword'; + } + }; + } + } + } + } + } +}); +``` + +This example would mark the entire passage text as a `keyword` token. + +- See [CodeMirror's syntax mode documentation] for details on what the `mode` + function should return, and how to parse passage text. The function specified + here will be used as the second argument to `CodeMirror.defineMode()`. +- Specifically, as the documentation notes, your CodeMirror mode must not modify + the global scope in any way. +- Twine manages the name of your syntax mode and sets the passage editor's + CodeMirror instance accordingly. You must not rely on the name of the syntax + mode being formatted in a particular way. This is to prevent one story format + from interfering with another's functioning. +- You must use CodeMirror's built-in tokens. Twine contains styling for these + tokens that will adapt to the user-selected theme (e.g. light or dark). It + doesn't appear that CodeMirror contains documentation for what these are apart + from [its own CSS](codemirror-css), unfortunately. The section commented + `DEFAULT THEME` lists available ones. +- A future version of Twine might allow custom tokens and appearance. +- A story format mode has no access to the story that the passage belongs to. It + must parse the text on its own terms. + +## CodeMirror Commands and Toolbar + +An editor toolbar can be specified by a format, which will appear between the +built-in one and the passage text. A toolbar must specify custom CodeMirror +commands that are triggered by buttons in the toolbar. + +- A format can only have one toolbar. +- A toolbar can only use CodeMirror commands defined by the format. These + commands can consist of any code, however, which in turn may call other + CodeMirror commands or otherwise do whatever it likes. + +## CodeMirror Toolbar + +A CodeMirror toolbar is specified through a hydrated function: + +```javascript +window.storyFormat({ + "name": "My Story Format", + "version": "1.0.0", + "editorExtensions": { + "twine": { + "^2.4.0-alpha1": { + "codeMirror": { + toolbar(editor, environment) { + return [ + { + type: 'button', + command: 'customCommand', + icon: 'data:image/svg+xml,...', + label: 'Custom Command' + }, + { + type: 'menu', + icon: 'data:image/svg+xml,...', + label: 'Menu', + items: [ + { + type: 'button', + command: 'customCommand2', + disabled: true, + icon: 'data:image/svg+xml,...', + label: 'Custom Commmand 2' + }, + { + type: 'separator' + }, + { + type: 'button', + command: 'customCommand3', + icon: 'data:image/svg+xml,...', + label: 'Custom Command 3' + } + ] + } + ]; + } + } + } + } + } +}); +``` + +The `toolbar` function receives two arguments from Twine: + +- `editor` is the CodeMirror editor instance the toolbar is attached to. This is + provided so that the toolbar can enable or disable items appropriately--for + example, based on whether the user has selected any text. See [the CodeMirror + API documentation](https://codemirror.net/doc/manual.html#api) for methods and + properties available on this object. As the documentation indicates, you may + use methods that start with either `doc` or `cm`; for example, + `editor.getSelection()`. +- `environment` is an object with information related to Twine itself: + + - `environment.appTheme` is either the string `dark` or `light`, depending on + the current app theme used. If the user has chosen to have the Twine app + theme match the system theme, then this will reflect the current system + theme. This property is provided so that the toolbar can vary its icons + based on the app theme. + - `environment.locale` is a string value containing the user-set locale. If + the user has chosen to have the Twine app use the system locale, this value + will reflect that as well. This property is provided so that the toolbar can + localize button and menu labels. + +It must follow these rules: + +- The toolbar function must be side-effect free. Twine will call it repeatedly + while the user is working. It should only return what the toolbar should be + given the state passed to it. It must never change the CodeMirror editor + passed to it. Changes should occur in toolbar commands instead (described + below). +- Changing `environment` properties has no effect. +- Do not change the order of toolbar items returned based on + `environment.locale` (e.g. reverse the order for right-to-left locales). Twine + will handle this for you. +- Avoid changing the contents of the toolbar based on CodeMirror state. Instead, + enable and disable items. +- If you would like to use a built-in CodeMirror command in your toolbar, write + a custom command that calls `editor.execCommand()`. +- The toolbar function must return an array of objects. The `type` property on + the object describes what kind of item to display. This property is required + on all items. + +### type: 'button' + +This displays a button which runs a CodeMirror command. Other properties: + +| Property Name | Type | Required | Description | +| ------------- | ------- | ------------------ | ---------------------------------------------------------------------------------------------------------------------- | +| `command` | string | yes | The name of the CodeMirror command to run when | +| `disabled` | boolean | no | If true, then the button is disabled. By default, buttons are enabled. | +| `icon` | string | depends on context | Used as the `src` attribute for the tag used in the button. Using a `data:` URL is recommended but not required. | +| `label` | string | yes | The label text to display. This cannot contain HTML. | + +The `icon` property is required if this toolbar item is not inside a menu. +Inside of a menu, the `icon` property is forbidden. + +### type: 'menu' + +This displays a drop-down menu. Other properties: + +| Property Name | Type | Required | Description | +| ------------- | ------- | -------- | ---------------------------------------------------------------------------------------------------------------------- | +| `disabled` | boolean | no | If true, then the button is disabled. By default, menus are enabled. | +| `icon` | string | yes | Used as the `src` attribute for the tag used in the button. Using a `data:` URL is recommended but not required. | +| `items` | array | yes | Items in this menu. | +| `label` | string | yes | The label text to display. This cannot contain HTML. | + +The `items` property must only contain objects with type `button` or `separator`. + +### type: 'separator' + +This displays a separator line in a menu. This type of item has no other +properties, and is only allowed in the `items` property of a menu item. + +## CodeMirror Commands + +The commands used by a CodeMirror toolbar are specified through hydrated functions: + +```javascript +window.storyFormat({ + "name": "My Story Format", + "version": "1.0.0", + "editorExtensions": { + "twine": { + "^2.4.0-alpha1": { + "codeMirror": { + "commands": { + customCommand1(editor) { + editor.getDoc().replaceSelection('Example text'); + }, + customCommand2(editor) { + const doc = editor.getDoc(); + + doc.replaceSelection(doc.getSelection().toUpperCase()); + } + } + } + } + } +}); +``` + +- When invoking a command from a story format toolbar, the name of the command + must match exactly, including case. +- Twine namespaces your commands (and their connections to the toolbar) so that + commands specified by one story format do not interfere with another story + format's, or the commands of a different version of that story format. You + must not rely on the name assigned to your commands by Twine, as this + namespacing may change in future versions. A possible way for story format + commands to reference each other using the `hydrate` property is: + +```javascript +function customCommand1(editor) { + editor.getDoc().replaceSelection('Example text'); +} + +function customCommand2(editor) { + const doc = editor.getDoc(); + + doc.replaceSelection(doc.getSelection().toUpperCase()); + customCommand1(editor); +} + +this.codeMirror = { + commands: {customCommand1, customCommand2} +}; +``` + +## Parsing References + +A story format can define _references_ in a story. References are secondary +connections between two passages. Unlike links: + +- References to nonexistent passages do not show a broken link line in the story + map. +- New passages are not automatically created for users when they add a new + reference in passage text. +- Renaming a passage does not affect passage text that contains a reference to + that passage. (e.g. doing a find/replace for text) +- References are drawn in Twine using a dotted line, though this appearance may + change in future versions. +- Twine does not parse any references in itself. References are reserved for + story format use. + +In order to use references, a story format must define a parser through a hydrated function: + +```javascript +window.storyFormat({ + "name": "My Story Format", + "version": "1.0.0", + "editorExtensions": { + "twine": { + "^2.4.0-alpha1": { + "references": { + parsePassageText(text) { + return text.split(/\s/); + } + } + } + } +}); +``` + +The `parsePassageText` function must: + +- Be synchronous. +- Return an array of strings, each string being the name of a passage being referred to in this text. +- Return an empty array if there are no references in the text. +- Avoid returning duplicate results; e.g. if some text contains multiple + references to the same passage, only one array item for that passage should be + returned. Including duplicates will not cause an error in Twine, but it will + slow it down. +- Have no side effects. It will be called repeatedly by Twine. + +## All Together + +Below is an example of a hydrated story format demonstrating all editor extensions available. + +```javascript +window.storyFormat({ + "name": "My Story Format", + "version": "1.0.0", + "editorExtensions": { + "twine": { + "^2.4.0-alpha1": { + "codeMirror": { + "commands": { + upperCase(editor) { + const doc = editor.getDoc(); + + doc.replaceSelection(doc.getSelection().toUpperCase()); + } + }, + mode() { + return { + startState() { + return {}; + }, + token(stream, state) { + stream.skipToEnd(); + return 'keyword'; + } + }; + }, + toolbar(editor, environment) { + return [ + { + type: 'button', + command: 'upperCase', + icon: 'data:image/svg+xml,...', + label: 'Uppercase Text' + } + ]; + } + }, + "references": { + parsePassageText(text) { + return text.match(/--.*?--/g); + } + } + } + } + } +}); +``` + +This specifies: + +- A CodeMirror mode which marks the entire passage as a keyword. +- A toolbar with a single command, "Uppercase Text". +- A reference parser that treats all text surrounded by two dashes (`--`) as a + reference. + +[format-specs]: https://github.com/iftechfoundation/twine-specs +[semver npm package]: https://docs.npmjs.com/cli/v6/using-npm/semver +[semantic versioning]: https://semver.org +[codemirror]: https://codemirror.net +[codemirror's syntax mode documentation]: https://codemirror.net/doc/manual.html#modeapi +[codemirror-css]: https://github.com/codemirror/CodeMirror/blob/master/lib/codemirror.css +[story-format-starter]: https://github.com/klembot/twine-2-story-format-starter From 3122e299ec9bcb8e479fec3626a6e6b6f2f32ec6 Mon Sep 17 00:00:00 2001 From: Chris Klimas Date: Sun, 19 Sep 2021 20:46:48 -0400 Subject: [PATCH 13/13] Bump version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 29806ad74..e53972e33 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "Twine", - "version": "2.4.0-alpha1", + "version": "2.4.0-beta1", "description": "a GUI for creating nonlinear stories", "author": "Chris Klimas ", "license": "GPL-3.0",