diff --git a/cypress/e2e/functional/document_tests/exemplar_test_spec.js b/cypress/e2e/functional/document_tests/exemplar_test_spec.js new file mode 100644 index 0000000000..8bc2ee1815 --- /dev/null +++ b/cypress/e2e/functional/document_tests/exemplar_test_spec.js @@ -0,0 +1,109 @@ +import SortedWork from "../../../support/elements/common/SortedWork"; +import ClueCanvas from '../../../support/elements/common/cCanvas'; +import DrawToolTile from '../../../support/elements/tile/DrawToolTile'; +import TextToolTile from "../../../support/elements/tile/TextToolTile"; + +let sortWork = new SortedWork, + clueCanvas = new ClueCanvas, + drawToolTile = new DrawToolTile, + textToolTile = new TextToolTile; + +// This unit has `initiallyHideExemplars` set, and an exemplar defined in curriculum +const queryParams1 = `${Cypress.config("qaConfigSubtabsUnitStudent5")}`; +const exemplarName = "Ivan Idea: First Exemplar"; + +function beforeTest(params) { + cy.clearQAData('all'); + cy.visit(params); + cy.waitForLoad(); +} + +function drawSmallRectangle(x, y) { + drawToolTile.getDrawToolRectangle().last().click(); + drawToolTile.getDrawTile().last() + .trigger("mousedown", x, y) + .trigger("mousemove", x+25, y+25) + .trigger("mouseup", x+25, y+25); +} + +function addText(x, y, text) { + drawToolTile.getDrawToolText().last().click(); + drawToolTile.getDrawTile().last() + .trigger("mousedown", x, y) + .trigger("mouseup", x, y); + drawToolTile.getDrawTile().last() + .trigger("mousedown", x, y) + .trigger("mouseup", x, y); + drawToolTile.getTextDrawing().get('textarea').type(text + "{enter}"); +} + +context('Exemplar Documents', function () { + it('Unit with exemplars hidden initially, revealed 3 drawings and 3 text tiles', function () { + beforeTest(queryParams1); + cy.openTopTab('sort-work'); + sortWork.checkDocumentInGroup("No Group", exemplarName); + sortWork.getSortWorkItemByTitle(exemplarName).parents('.list-item').should("have.class", "private"); + + cy.log("Create 3 drawing tiles with 3 events"); + clueCanvas.addTile("drawing"); + drawSmallRectangle(100, 50); + drawSmallRectangle(200, 50); + drawSmallRectangle(300, 50); + + clueCanvas.addTile("drawing"); + drawSmallRectangle(100, 50); + drawSmallRectangle(200, 50); + drawSmallRectangle(300, 50); + + clueCanvas.addTile("drawing"); + drawSmallRectangle(100, 50); + drawSmallRectangle(200, 50); + drawSmallRectangle(300, 50); + + cy.log("Create 3 text tiles and put 10 words in them"); + clueCanvas.addTile("text"); + textToolTile.enterText("one two three four five six seven eight nine ten"); + + clueCanvas.addTile("text"); + textToolTile.enterText("one two three four five six seven eight nine ten"); + + clueCanvas.addTile("text"); + textToolTile.enterText("one two three four five six seven eight nine"); + drawToolTile.getDrawTile().eq(0).click(); // text is saved in onBlur + // Still private? + sortWork.getSortWorkItemByTitle(exemplarName).parents('.list-item').should("have.class", "private"); + textToolTile.enterText(" ten"); + drawToolTile.getDrawTile().eq(0).click(); + + // Now the exemplar should be revealed + sortWork.getSortWorkItemByTitle(exemplarName).parents('.list-item').should("not.have.class", "private"); + }); + + it('Exemplar revealed by 3 drawings that include labels', function () { + beforeTest(queryParams1); + cy.openTopTab('sort-work'); + sortWork.checkDocumentInGroup("No Group", exemplarName); + sortWork.getSortWorkItemByTitle(exemplarName).parents('.list-item').should("have.class", "private"); + + cy.log("Create 3 drawing tiles with 3 events and a label"); + clueCanvas.addTile("drawing"); + drawSmallRectangle(100, 50); + drawSmallRectangle(200, 50); + addText(300, 50, "one two three four five six seven eight nine ten"); + + clueCanvas.addTile("drawing"); + drawSmallRectangle(100, 50); + drawSmallRectangle(200, 50); + addText(300, 50, "one two three four five six seven eight nine ten"); + + clueCanvas.addTile("drawing"); + drawSmallRectangle(100, 50); + drawSmallRectangle(200, 50); + + // Still private? + sortWork.getSortWorkItemByTitle(exemplarName).parents('.list-item').should("have.class", "private"); + addText(300, 50, "one two three four five six seven eight nine ten"); + // Now the exemplar should be revealed + sortWork.getSortWorkItemByTitle(exemplarName).parents('.list-item').should("not.have.class", "private"); + }); +}); diff --git a/cypress/support/elements/common/SortedWork.js b/cypress/support/elements/common/SortedWork.js index 6a6af6fcb0..f17950a5bb 100644 --- a/cypress/support/elements/common/SortedWork.js +++ b/cypress/support/elements/common/SortedWork.js @@ -14,11 +14,17 @@ class SortedWork { getSortWorkItem() { return cy.get(".sort-work-view .sorted-sections .list-item .footer .info"); } + getSortWorkItemByTitle(title) { + return this.getSortWorkItem().contains(title); + } + getSortWorkGroup(groupName) { + return cy.get(".sort-work-view .sorted-sections .section-header-label").contains(groupName).parent().parent(); + } checkDocumentInGroup(groupName, doc) { - cy.get(".sort-work-view .sorted-sections .section-header-label").contains(groupName).parent().parent().find(".list .list-item .footer .info").should("contain", doc); + this.getSortWorkGroup(groupName).find(".list .list-item .footer .info").should("contain", doc); } checkDocumentNotInGroup(groupName, doc) { - cy.get(".sort-work-view .sorted-sections .section-header-label").contains(groupName).parent().parent().find(".list .list-item .footer .info").should("not.contain", doc); + this.getSortWorkGroup(groupName).find(".list .list-item .footer .info").should("not.contain", doc); } checkGroupIsEmpty(groupName){ cy.get(".sort-work-view .sorted-sections .section-header-label") diff --git a/src/components/tiles/text/text-tile.tsx b/src/components/tiles/text/text-tile.tsx index 2249165fb7..31ecc637ff 100644 --- a/src/components/tiles/text/text-tile.tsx +++ b/src/components/tiles/text/text-tile.tsx @@ -16,6 +16,7 @@ import { createTextPluginInstances, ITextPlugin } from "../../../models/tiles/te import { LogEventName } from "../../../lib/logger-types"; import { TextPluginsContext } from "./text-plugins-context"; import { TileToolbar } from "../../toolbar/tile-toolbar"; +import { countWords } from "../../../utilities/string-utils"; import "./toolbar/text-toolbar-registration"; import "./text-tile.sass"; @@ -250,8 +251,15 @@ export default class TextToolComponent extends BaseComponent // If the text has changed since the editor was focused, log the new text. const text = this.getContent().text; if (text !== this.textOnFocus) { + const plainText = this.getContent().asPlainText(); + const wordCount = countWords(plainText); const change = {args:[{ text }]}; - logTileChangeEvent(LogEventName.TEXT_TOOL_CHANGE, { operation: 'update', change, tileId: this.props.model.id }); + logTileChangeEvent(LogEventName.TEXT_TOOL_CHANGE, { + operation: 'update', + change, + plainText, + wordCount, + tileId: this.props.model.id }); } this.setState({ revision: this.state.revision + 1 }); // Force a rerender }; diff --git a/src/components/workspace/workspace.tsx b/src/components/workspace/workspace.tsx index b03c0ba365..c81bf33202 100644 --- a/src/components/workspace/workspace.tsx +++ b/src/components/workspace/workspace.tsx @@ -1,5 +1,5 @@ import { observer } from "mobx-react"; -import React from "react"; +import React, { useCallback, useEffect, useRef } from "react"; import { IBaseProps } from "../base"; import { useStores } from "../../hooks/use-stores"; import { DocumentWorkspaceComponent } from "../document/document-workspace"; @@ -7,6 +7,7 @@ import { ImageDragDrop } from "../utilities/image-drag-drop"; import { NavTabPanel } from "../navigation/nav-tab-panel"; import { ResizePanelDivider } from "./resize-panel-divider"; import { ResizablePanel } from "./resizable-panel"; +import { HotKeys } from "../../utilities/hot-keys"; import "./workspace.sass"; @@ -16,17 +17,33 @@ interface IProps extends IBaseProps { export const WorkspaceComponent: React.FC = observer((props) => { const stores = useStores(); const { appConfig: { navTabs: navTabSpecs }, - persistentUI: { navTabContentShown, workspaceShown } + persistentUI: { navTabContentShown, workspaceShown }, + exemplarController } = stores; + const hotKeys = useRef(new HotKeys()); let imageDragDrop: ImageDragDrop; + // For testing purposes, have cmd-shift-e reset all exemplars to their default state + const resetAllExemplars = useCallback(() => { + exemplarController.resetAllExemplars(); + }, [exemplarController]); + + useEffect(() => { + hotKeys.current.register({ + "cmd-shift-e": resetAllExemplars + }); + + }, [resetAllExemplars]); + const handleDragOverWorkspace = (e: React.DragEvent) => { imageDragDrop?.dragOver(e); }; return ( -
+
hotKeys.current.dispatch(e)}>
{ - const visible = "visible" in value && value.visible; + const visible = typeof value === "object" && value !== null && "visible" in value && value.visible; this.db.stores.documents.setExemplarVisible(exemplarId, visible); }; diff --git a/src/lib/db.ts b/src/lib/db.ts index 5bc1340898..c89daeca4a 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -153,7 +153,7 @@ export class DB { this.firebase.setFirebaseUser(firebaseUser); this.firestore.setFirebaseUser(firebaseUser); if (!options.dontStartListeners) { - const { persistentUI, user, db, unitLoadedPromise} = this.stores; + const { persistentUI, user, db, unitLoadedPromise, exemplarController} = this.stores; // Start fetching the persistent UI. We want this to happen as early as possible. persistentUI.initializePersistentUISync(user, db); @@ -163,6 +163,7 @@ export class DB { // We need those types to be registered so the listeners can safely create documents. unitLoadedPromise.then(() => { this.listeners.start().then(resolve).catch(reject); + exemplarController.initialize(user, db); }); } } diff --git a/src/lib/firebase.ts b/src/lib/firebase.ts index dd7ffa8c72..102b0e084f 100644 --- a/src/lib/firebase.ts +++ b/src/lib/firebase.ts @@ -112,6 +112,10 @@ export class Firebase { return `${this.getUserPath(user)}/exemplars`; } + public getExemplarStatePath(user: UserModelType) { + return `${this.getUserExemplarsPath(user)}/state`; + } + // Published learning logs metadata public getLearningLogPublicationsPath(user: UserModelType) { return `${this.getClassPath(user)}/publications`; diff --git a/src/lib/logger.ts b/src/lib/logger.ts index f902f7d926..e2800fd1ad 100644 --- a/src/lib/logger.ts +++ b/src/lib/logger.ts @@ -15,7 +15,7 @@ const logManagerUrl: Record = { const productionPortal = "learn.concord.org"; -interface LogMessage { +export interface LogMessage { // these top-level properties are treated specially by the log-ingester: // https://github.com/concord-consortium/log-ingester/blob/a8b16fdb02f4cef1f06965a55c5ec6c1f5d3ae1b/canonicalize.js#L3 application: string; @@ -55,6 +55,8 @@ interface PendingMessage { method?: LogEventMethod; } +type ILogListener = (logMessage: LogMessage) => void; + export class Logger { public static isLoggingEnabled = false; private static _instance: Logger; @@ -108,6 +110,7 @@ export class Logger { private stores: IStores; private appContext: Record = {}; private session: string; + private logListeners: ILogListener[] = []; private constructor(stores: IStores, appContext = {}) { this.stores = stores; @@ -115,11 +118,18 @@ export class Logger { this.session = uuid(); } + public registerLogListener(listener: ILogListener) { + this.logListeners.push(listener); + } + private formatAndSend(time: number, event: LogEventName, parameters?: Record, method?: LogEventMethod) { const eventString = LogEventName[event]; const logMessage = this.createLogMessage(time, eventString, parameters, method); sendToLoggingService(logMessage, this.stores.user); + for (const listener of this.logListeners) { + listener(logMessage); + } } private createLogMessage( diff --git a/src/models/stores/documents.ts b/src/models/stores/documents.ts index 84fa7a0a59..1a7aa7ed74 100644 --- a/src/models/stores/documents.ts +++ b/src/models/stores/documents.ts @@ -4,7 +4,7 @@ import { observable } from "mobx"; import { AppConfigModelType } from "./app-config-model"; import { DocumentModelType } from "../document/document"; import { - DocumentType, LearningLogDocument, LearningLogPublication, OtherDocumentType, OtherPublicationType, + DocumentType, ExemplarDocument, LearningLogDocument, LearningLogPublication, OtherDocumentType, OtherPublicationType, PersonalDocument, PersonalPublication, PlanningDocument, ProblemDocument, ProblemPublication } from "../document/document-types"; import { getTileEnvironment } from "../tiles/tile-environment"; @@ -152,6 +152,14 @@ export const DocumentsModel = types ? user1.lastName.localeCompare(user2.lastName) : user1.firstName.localeCompare(user2.firstName); }); + }, + + get visibleExemplarDocuments() { + return self.byType(ExemplarDocument).filter(e => self.isExemplarVisible(e.key)); + }, + + get invisibleExemplarDocuments() { + return self.byType(ExemplarDocument).filter(e => !self.isExemplarVisible(e.key)); } })) .views(self => ({ @@ -188,7 +196,6 @@ export const DocumentsModel = types self.visibleExemplars.delete(exemplarId); } } - })) .actions((self) => { const add = (document: DocumentModelType) => { diff --git a/src/models/stores/exemplar-controller-rules.ts b/src/models/stores/exemplar-controller-rules.ts new file mode 100644 index 0000000000..57d14342aa --- /dev/null +++ b/src/models/stores/exemplar-controller-rules.ts @@ -0,0 +1,59 @@ +import { kDrawingTileType } from "../../plugins/drawing/model/drawing-types"; +import { kTextTileType } from "../tiles/text/text-content"; +import { BaseExemplarControllerModelType } from "./exemplar-controller"; + +// Rules for the ExemplarController are specified as: +// - A name +// - A "test" function that is run over the controller's state. +// If the rule matches it should return a list of tiles that satisfy the rule +// If it does not match, it should return false. +// - A "reset" function that updates the controller's state after +// the rule has matched. This typically involves transferring the involved +// tile records to the 'complete' list. + +interface IExemplarControllerRule { + name: string; + test: (model: BaseExemplarControllerModelType) => string[] | false; + reset: (model: BaseExemplarControllerModelType, tiles: string[]) => void; +} + +// "3 drawings/3 labels" Rule: reveal an exemplar for each: +// 3 drawing tiles, each with at least 3 actions AND +// 3 labels with least 10 words each, where a label can be a text tile or a text object in a drawing. + +const kDrawingMinActivityLevel = 3; +const kLabelMinWords = 10; + +const threeDrawingsRule: IExemplarControllerRule = { + name: "3 drawings/3 labels", + test: (model: BaseExemplarControllerModelType) => { + let foundDrawings = 0, foundLabels = 0; + const tileIds: string[] = []; + for (const [key, tile] of model.inProgressTiles.entries()) { + if (tile.type === kTextTileType && tile.wordCount >= kLabelMinWords && foundLabels < 3) { + foundLabels++; + tileIds.push(key); + } + if (tile.type === kDrawingTileType) { + if (tile.activityLevel >= kDrawingMinActivityLevel && foundDrawings < 3) { + foundDrawings++; + tileIds.push(key); + } + if (tile.wordCount >= kLabelMinWords && foundLabels < 3) { + foundLabels++; + if (!tileIds.includes(key)) tileIds.push(key); + } + } + + if (foundDrawings >= 3 && foundLabels >= 3) { + return tileIds; + } + } + return false; + }, + reset: (model: BaseExemplarControllerModelType, tiles: string[]) => { + model.markTilesComplete(tiles); + } +}; + +export const allExemplarControllerRules = [ threeDrawingsRule ]; diff --git a/src/models/stores/exemplar-controller.ts b/src/models/stores/exemplar-controller.ts new file mode 100644 index 0000000000..45570635bd --- /dev/null +++ b/src/models/stores/exemplar-controller.ts @@ -0,0 +1,192 @@ +import { applySnapshot, types, onSnapshot, detach } from "mobx-state-tree"; +import _ from "lodash"; +import { UserModelType } from "./user"; +import { DB } from "../../lib/db"; +import { safeJsonParse } from "../../utilities/js-utils"; +import { LogEventName } from "../../lib/logger-types"; +import { DocumentsModelType } from "./documents"; +import { Logger, LogMessage } from "../../lib/logger"; +import { allExemplarControllerRules } from "./exemplar-controller-rules"; +import { kDrawingTileType } from "../../plugins/drawing/model/drawing-types"; +import { kTextTileType } from "../tiles/text/text-content"; +import { countWords } from "../../utilities/string-utils"; + +/** + * Information that the exemplar controller stores about specific tiles. + * At the moment this includes two numbers representing the level of activity, and the + * length of text in the tile. So far we are only tracking Text and Drawing tiles. + */ +export const InProgressTileModel = types + .model("InProgressTile", { + id: types.identifier, + type: types.string, + activityLevel: 0, + wordCount: 0 + }); + +// The database structure of this object is expected to change frequently as we develop +// this feature, so keep an explicit version number to make migrations easier. +const kExemplarControllerStateVersion = "1"; + +export const BaseExemplarControllerModel = types + .model("BaseExemplarController", { + version: types.optional(types.literal(kExemplarControllerStateVersion), kExemplarControllerStateVersion), + // Store separate maps of tiles that have already been "used" to open up + // a new exemplar, and those that have not. + completeTiles: types.map(InProgressTileModel), + inProgressTiles: types.map(InProgressTileModel) + }) + .volatile((self) => ({ + documentsStore: undefined as DocumentsModelType | undefined, + db: undefined as DB|undefined, + firebasePath: undefined as string|undefined + })) + .actions((self) => ({ + /** + * Writes to the database to indicate whether the current user has access to the given exemplar. + */ + setExemplarVisibility(key: string, isVisible: boolean) { + if (self.db) { + self.db.firebase.ref(self.firebasePath).child(`${key}/visible`).set(isVisible); + } + } + })) + .actions((self) => ({ + /** + * Writes to the database to clear any visible exemplars. + */ + resetAllExemplars() { + for (const key of self.documentsStore?.visibleExemplars || []) { + self.setExemplarVisibility(key, false); + } + }, + showRandomExemplar() { + const chosen = _.sample(self.documentsStore?.invisibleExemplarDocuments); + if (chosen) { + self.setExemplarVisibility(chosen.key, true); + } + }, + /** + * Moves our records of the tiles from the 'inProgress' map to the 'complete' map. + * @param keys + */ + markTilesComplete(keys: string[]) { + for (const key of keys) { + const tile = self.inProgressTiles.get(key); + if (tile) { + detach(tile); + self.completeTiles.put(tile); + } + } + } + })); + +export const ExemplarControllerModel = BaseExemplarControllerModel + .actions((self) => ({ + /** + * Makes any appropriate changes to exemplars based on the current state. + * This should be called after any state changes that may require action. + */ + runAllRules() { + for (const rule of allExemplarControllerRules) { + const result = rule.test(self); + if (result) { + self.showRandomExemplar(); + rule.reset(self, result); + } + } + }, + getOrCreateInProgressTile(id: string, type: string) { + let tile = self.inProgressTiles.get(id); + if (!tile) { + tile = self.inProgressTiles.put({ id, type }); + } + return tile; + } + })) + .actions((self) => ({ + /** + * Take any needed actions after a user action, as represented by a log event. + * @param logMessage + */ + processLogMessage(logMessage: LogMessage) { + let needsUpdate = false; + + // Text tiles + if (logMessage.event === LogEventName[LogEventName.TEXT_TOOL_CHANGE]) { + // For text tiles, track the current number of words in the tile. + const tileId = logMessage.parameters.tileId; + if (self.completeTiles.has(tileId)) { + // Don't need to update status on tiles that are 'completed' + return; + } + const operation = logMessage.parameters?.operation; + if (_.isString(operation) && operation === "update") { + const tile = self.getOrCreateInProgressTile(tileId, kTextTileType); + const wordCount = logMessage.parameters?.wordCount; + if (wordCount && wordCount !== tile.wordCount) { + tile.wordCount = wordCount; + needsUpdate = true; + } + } + } + + if (logMessage.event === LogEventName[LogEventName.DRAWING_TOOL_CHANGE]) { + // For drawing tile, "activityLevel" counts the number of objects that the user has added. + // WordCount tracks the content of the longest 'text' object in the drawing. + const tileId = logMessage.parameters.tileId; + if (self.completeTiles.has(tileId)) { + return; + } + const operation = logMessage.parameters?.operation; + if (_.isString(operation) + && ["setText", "addObject", "addAndSelectObject", "duplicateObjects"].includes(operation)) { + const tile = self.getOrCreateInProgressTile(tileId, kDrawingTileType); + + if (operation === "setText") { + const args = logMessage.parameters?.args; + if (args && _.isArray(args) && args.length > 0) { + const words = countWords(args[0]); + if (words > tile.wordCount) { + tile.wordCount = words; + needsUpdate = true; + } + } + } else { + // Add or duplicate object operation + tile.activityLevel ++; + needsUpdate = true; + } + } + } + + if (needsUpdate) { + self.runAllRules(); + } + } + })) + .actions(self => ({ + async initialize(user: UserModelType, db: DB) { + self.db = db; + self.documentsStore = db.stores.documents; + self.firebasePath = db.firebase.getUserExemplarsPath(user); + const statePath = db.firebase.getExemplarStatePath(user); + const stateRef = db.firebase.ref(statePath); + const stateVal = (await stateRef.once("value"))?.val(); + const state = safeJsonParse(stateVal); + if (state) { + applySnapshot(self, state); + } + Logger.Instance.registerLogListener(self.processLogMessage); + + onSnapshot(self, (snapshot)=>{ + const snapshotStr = JSON.stringify(snapshot); + const updateRef = db.firebase.ref(statePath); + updateRef.set(snapshotStr); + }); + } + })); + +export type BaseExemplarControllerModelType = typeof BaseExemplarControllerModel.Type; + +export type ExemplarControllerModelType = typeof ExemplarControllerModel.Type; diff --git a/src/models/stores/stores.ts b/src/models/stores/stores.ts index fbdf475546..38bfb9a6fa 100644 --- a/src/models/stores/stores.ts +++ b/src/models/stores/stores.ts @@ -33,6 +33,7 @@ import { urlParams } from "../../utilities/url-params"; import { createAndLoadExemplarDocs } from "./create-exemplar-docs"; import curriculumConfigJson from "../../clue/curriculum-config.json"; import { gImageMap } from "../image-map"; +import { ExemplarControllerModel, ExemplarControllerModelType } from "./exemplar-controller"; export interface IStores extends IBaseStores { problemPath: string; @@ -48,6 +49,7 @@ export interface IStores extends IBaseStores { unitLoadedPromise: Promise; sectionsLoadedPromise: Promise; startedLoadingUnitAndProblem: boolean; + exemplarController: ExemplarControllerModelType; } export interface ICreateStores extends Partial { @@ -91,6 +93,7 @@ class Stores implements IStores{ unitLoadedPromise: Promise; sectionsLoadedPromise: Promise; startedLoadingUnitAndProblem: boolean; + exemplarController: ExemplarControllerModelType; constructor(params?: ICreateStores){ // This will mark all properties as observable @@ -150,6 +153,7 @@ class Stores implements IStores{ this.unitLoadedPromise = when(() => this.unit !== defaultUnit); this.sectionsLoadedPromise = when(() => this.problem.sections.length > 0); + this.exemplarController = ExemplarControllerModel.create(); } get tabsToDisplay() { diff --git a/src/models/tiles/text/text-content.ts b/src/models/tiles/text/text-content.ts index f6afc3d966..77642babfd 100644 --- a/src/models/tiles/text/text-content.ts +++ b/src/models/tiles/text/text-content.ts @@ -1,6 +1,6 @@ import { types, Instance, SnapshotIn } from "mobx-state-tree"; import { - convertDocument, CustomEditor, Editor, EditorValue, htmlToSlate, serializeValue, slateToHtml, textToSlate + convertDocument, CustomEditor, Editor, EditorValue, htmlToSlate, serializeValue, slateToHtml, textToSlate, slateToText } from "@concord-consortium/slate-editor"; import { ITileExportOptions } from "../tile-content-info"; import { TileContentModel } from "../tile-content"; @@ -81,6 +81,14 @@ export const TextContentModel = TileContentModel default: return textToSlate(self.joinText); } + }, + asPlainText(): string { + if (self.format) { + return slateToText(this.asSlate()); + } else { + // Undefined format means it's plain text already + return self.joinText; + } } })) .views(self => ({ diff --git a/src/utilities/string-utils.ts b/src/utilities/string-utils.ts index 4040759eb3..7db955c285 100644 --- a/src/utilities/string-utils.ts +++ b/src/utilities/string-utils.ts @@ -2,3 +2,8 @@ export const escapeBackslashes = (text: string) => text.replaceAll(`\\`, `\\\\`) export const escapeDoubleQuotes = (text: string) => text.replaceAll(`"`, `\\"`); export const removeNewlines = (text: string) => text.replace(/\r?\n|\r/g, ""); export const removeTabs = (text: string) => text.replace(/\t/g, ""); + +export const countWords = (text: string) => { + const matches = text.match(/\b\w+\b/g); // transitions from non-word to word character. + return matches ? matches.length : 0; +};